diff --git a/controller/internal/api/r575_deploy_warning_test.go b/controller/internal/api/r575_deploy_warning_test.go new file mode 100644 index 0000000..86fd7dd --- /dev/null +++ b/controller/internal/api/r575_deploy_warning_test.go @@ -0,0 +1,32 @@ +package api + +import ( + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-575: the deploy answer renders the overcommit warning in the reader's language — English for an +// English request (it used to be Hungarian on every page), Hungarian by default. +func TestR575_OvercommitWarningFollowsTheLanguage(t *testing.T) { + r := &Router{} + en := r.deployWarningData(httptest.NewRequest("POST", "/api/stacks/x/deploy?lang=en", nil), stacks.KeyMemoryOvercommitWarning) + hu := r.deployWarningData(httptest.NewRequest("POST", "/api/stacks/x/deploy?lang=hu", nil), stacks.KeyMemoryOvercommitWarning) + if en == nil || hu == nil { + t.Fatal("a warning key must produce a warning") + } + if !strings.Contains(en["warning"], "memory") || strings.Contains(en["warning"], "memóri") { + t.Errorf("English page got %q", en["warning"]) + } + if !strings.Contains(hu["warning"], "memóri") { + t.Errorf("Hungarian page got %q", hu["warning"]) + } + if strings.Contains(en["warning"], "err.stacks.") || strings.Contains(hu["warning"], "err.stacks.") { + t.Error("a raw key reached the page") + } + if d := r.deployWarningData(httptest.NewRequest("POST", "/", nil), ""); d != nil { + t.Errorf("no warning -> no data, got %v", d) + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 1693336..8b967bc 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -544,8 +544,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri // was true — misleading for API/script consumers. Report that the deploy STARTED, not that it // finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI. resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")} - if warning != "" { - resp.Data = map[string]string{"warning": warning} + if d := r.deployWarningData(req, warning); d != nil { + resp.Data = d } writeJSON(w, http.StatusAccepted, resp) @@ -1651,3 +1651,13 @@ func startAnswer(name, action, state string) apiResponse { Message: "Stack " + name + " " + action + " requested — state now: " + state, Data: map[string]interface{}{"state": state}} } + +// deployWarningData renders the deploy's soft warning for the answer. R-575: DeployStack hands back the +// warning's bundle KEY, never a sentence, so it is rendered here in the reader's language. nil = none. +// Pinned by TestR575_OvercommitWarningFollowsTheLanguage. +func (r *Router) deployWarningData(req *http.Request, warningKey string) map[string]string { + if warningKey == "" { + return nil + } + return map[string]string{"warning": r.msg(req, warningKey)} +} diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index a213cac..ce4e22c 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -248,6 +248,9 @@ const ( // 6. Save app.yaml // 7. Run docker compose up -d with env vars // 8. Update in-memory stack state +// +// The string it returns is a bundle KEY for a soft warning ("" = none), never a sentence (R-575): the +// API renders it in the request's language. func (m *Manager) DeployStack(req DeployRequest) (string, error) { // Atomically check and set the Deploying flag to prevent concurrent deploys (H1 fix). m.mu.Lock() @@ -1472,14 +1475,15 @@ func randomAlphanumeric(length int) (string, error) { // update replaces the app's own current request, so counting both would refuse an update that fits. // A deploy releases nothing and passes 0, 0. // -// Returns the refusal (nil = admitted) and the soft overcommit warning. +// Returns the refusal (nil = admitted) and the soft overcommit warning as a bundle KEY (R-575): no +// language is known here, so the caller that answers a request renders it in that request's language. // // v0.253.0 (R-557): the refusal is an ERROR rather than a sentence, and it carries BOTH its kind // (ErrNotEnoughMemory, so api.deployStatusFor still answers 409) and its message key (so the // 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, warning string) { +func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warningKey string) { 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 @@ -1522,9 +1526,9 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim _, currentLimitMB := m.CommittedMemory() currentLimitMB -= releasedLimitMB if newLimitMB > 0 && currentLimitMB+newLimitMB > totalMB { - warning = msgHU("err.stacks.memory_overcommit_warning") + warningKey = KeyMemoryOvercommitWarning } - return nil, warning + return nil, warningKey } // loginAppliedEveryStart is the register of `type: password` fields whose app APPLIES the env value at diff --git a/controller/internal/stacks/deploy_errors.go b/controller/internal/stacks/deploy_errors.go index 85be048..a67d94c 100644 --- a/controller/internal/stacks/deploy_errors.go +++ b/controller/internal/stacks/deploy_errors.go @@ -2,8 +2,6 @@ package stacks import ( "errors" - - "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" ) // R-553 — the deploy path's refusals carry a KIND, so the API can pick its status code without @@ -32,21 +30,8 @@ var ( ErrStackDeploying = errors.New("stack is being deployed") ) -// msgHU renders a bundle message in Hungarian. -// -// It exists for the few strings this package returns as TEXT rather than as an error — the soft -// overcommit warning is the only one today. Those are display strings with no error to carry a key, -// so the copy lives in the bundle (a translator sees it, and scripts/i18n_go_parity.py pins it) while -// the value handed back is still Hungarian. **The consequence, stated rather than hidden: such a -// string renders Hungarian on an English page.** Filed as R-575; the fix is for the caller to carry -// the key the way Alert and UpdateRefusal do, not for this helper to guess a language it cannot know. -func msgHU(key string, args ...interface{}) string { - b, err := i18n.Shared() - if err != nil { - return key - } - if len(args) == 0 { - return b.Msg(i18n.Default, key) - } - return b.Msgf(i18n.Default, key, args...) -} +// KeyMemoryOvercommitWarning is the soft overcommit warning's bundle key. It travels as a KEY from +// memoryVerdict through DeployStack to the API, which renders it in the request's language (R-575 — +// it used to be rendered here in Hungarian, so an English page showed a Hungarian line). Pinned by +// TestR575_OvercommitWarningFollowsTheLanguage. +const KeyMemoryOvercommitWarning = "err.stacks.memory_overcommit_warning" diff --git a/controller/internal/stacks/r575_overcommit_key_test.go b/controller/internal/stacks/r575_overcommit_key_test.go new file mode 100644 index 0000000..7defce3 --- /dev/null +++ b/controller/internal/stacks/r575_overcommit_key_test.go @@ -0,0 +1,24 @@ +package stacks + +import ( + "io" + "log" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// R-575: the soft overcommit warning leaves memoryVerdict as a bundle KEY, never as Hungarian text, so +// the API can render it in the reader's language. PATH is emptied so the memory reading can never fall +// through to `docker info` on the host running the test. +func TestR575_OvercommitWarningIsAKey(t *testing.T) { + t.Setenv("PATH", "") + m := &Manager{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{}} + refusal, warning := m.memoryVerdict(0, 1<<30, 0, 0) // a limit no machine has: overcommit, no hard block + if refusal != nil { + t.Fatalf("no request -> no refusal, got %v", refusal) + } + if warning != KeyMemoryOvercommitWarning { + t.Fatalf("warning = %q, want the bundle key %q", warning, KeyMemoryOvercommitWarning) + } +}