diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index ffdb057..99768c6 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -1517,15 +1517,20 @@ func randomAlphanumeric(length int) (string, error) { // household reads it in its own language). One value where there used to be a sentence plus a // wrapper at each call site. An unreadable memory reading admits with a WARN, exactly as the deploy // always has. -func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warningKey string) { - reservedMB := m.cfg.System.ReservedMemoryMB +// InstallMemory is the ONE reading of the memory the install check judges: the guest's total, the reserve, and the +// installed apps' committed requests („used"). The install page (web deploy handler) shows exactly these, so the page +// and the check can never disagree (R-1125, 2026-10-17: the page used /proc's host-wide figures and blocked an app the +// check would accept, or the other way round). ok=false when the memory cannot be read. Pinned by +// TestR1125_InstallMemoryIsTheChecksReading. +func (m *Manager) InstallMemory() (totalMB, reservedMB, usedMB int, ok bool) { + reservedMB = m.cfg.System.ReservedMemoryMB totalMB, usedMB, memErr := system.GetMemoryMB() // F1: the controller container cannot read the guest's RAM cap from /proc (no lxcfs) or its own // cgroup (the cap is on the LXC ancestor). Prefer the guest cap from the Docker daemon (runs in the // LXC). And use the controller's OWN committed-memory accounting for "used" — accurate and cheap — // rather than host /proc RSS, which is unobservable-per-guest and would otherwise make this guard // either never fire (host total) or always fire (host used > guest cap). - if gt, ok := system.GuestMemTotalMB(); ok && gt > 0 { + if gt, gok := system.GuestMemTotalMB(); gok && gt > 0 { totalMB = gt memErr = nil } @@ -1534,6 +1539,14 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim } if memErr != nil { m.logger.Printf("[WARN] [stacks] Cannot read system memory: %v — skipping memory check", memErr) + return 0, 0, 0, false + } + return totalMB, reservedMB, usedMB, true +} + +func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warningKey string) { + totalMB, reservedMB, usedMB, ok := m.InstallMemory() + if !ok { return nil, "" } usedMB -= releasedReqMB diff --git a/controller/internal/stacks/r1125_install_memory_test.go b/controller/internal/stacks/r1125_install_memory_test.go new file mode 100644 index 0000000..70c13ef --- /dev/null +++ b/controller/internal/stacks/r1125_install_memory_test.go @@ -0,0 +1,48 @@ +package stacks + +import ( + "io" + "log" + "os" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// R-1125: the install page and the install check read memory the SAME way. „Used" is the installed (running) apps' +// committed requests — never /proc's host-wide figure, which the page used before 2026-10-17 (on scratch 9202 it +// showed 12,637 MB „needed" while the check counted 2,648 MB). +// RED-PROOF (2026-10-17): the deploy handler back on system.GetMemoryMB fails the source half. +func TestR1125_InstallMemoryIsTheChecksReading(t *testing.T) { + cfg := config.Default() + m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{ + "a": {Deployed: true, State: StateRunning, Meta: Metadata{Resources: ResourceHints{MemRequest: "600M", MemLimit: "1664M"}}}, + "b": {Deployed: true, State: StateRunning, Meta: Metadata{Resources: ResourceHints{MemRequest: "2048M", MemLimit: "4096M"}}}, + "c": {Deployed: true, State: StateStopped, Meta: Metadata{Resources: ResourceHints{MemRequest: "900M"}}}, + "d": {Deployed: false, Meta: Metadata{Resources: ResourceHints{MemRequest: "500M"}}}, + }} + _, reserved, used, ok := m.InstallMemory() + if !ok { + t.Skip("no memory reading on this platform") + } + if used != 2648 { + t.Fatalf("„used\" must be the running installed apps' requests (600+2048), got %d", used) + } + if reserved != cfg.System.ReservedMemoryMB { + t.Fatalf("reserve %d, want the configured %d", reserved, cfg.System.ReservedMemoryMB) + } + src, err := os.ReadFile("../web/handlers.go") + if err != nil { + t.Fatal(err) + } + body := string(src) + i := strings.Index(body, "// Memory info for deploy page") + if i < 0 { + t.Fatal("the deploy page's memory block moved — re-point this test") + } + block := body[i : i+1200] + if !strings.Contains(block, "s.stackMgr.InstallMemory()") || strings.Contains(block, "system.GetMemoryMB()") { + t.Fatalf("the install page must read memory through InstallMemory, the check's own reading:\n%s", block) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index ef20414..7c24179 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -688,9 +688,10 @@ func (s *Server) deployPageData(w http.ResponseWriter, r *http.Request, name str // Memory info for deploy page (only for non-deployed apps) if !alreadyDeployed { memInfo := map[string]interface{}{"Available": false} - totalMB, usedMB, memErr := system.GetMemoryMB() - if memErr == nil { - reservedMB := s.cfg.System.ReservedMemoryMB + // R-1125: the SAME reading the install check judges (guest total, reserve, installed apps' requests) — never + // /proc's host-wide figures, which made this box and the check disagree. + totalMB, reservedMB, usedMB, memOK := s.stackMgr.InstallMemory() + if memOK { usableMB := totalMB - reservedMB newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest) afterMB := usedMB + newReqMB