controller v0.265.0: R-634 cause fixed, held apps say so, OOM storm alarm, R-647 leftovers
gates / gates (push) Successful in 27s

R-634: a whole-box backup no longer stops/restarts a DEPLOYING app (the
measured cause of containers running under 'not deployed'); StopStack
and StartStack refuse a deploying stack for every caller.
R-625: held badge 'Stopped - restore needed', no Update button.
R-636: kernel oom_kill counter; 20+ in 30 min -> one app_oom_storm.
R-647: held error per reader, copy_holds key, two log wordings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 17:16:57 +02:00
parent 0a3026180a
commit 0054d4bd69
28 changed files with 832 additions and 48 deletions
+47 -11
View File
@@ -322,17 +322,6 @@ func (s *Server) localeFuncs(lang string) template.FuncMap {
}
return stk.UpdatePhaseLabel // no translation for this phase: the stored label, never an empty line
},
"updateErrorText": func(st interface{}) string { return stackOf(st).UpdateErrorIn(lang) },
"holdText": func(st interface{}) string {
stk := stackOf(st)
if stk.HoldReason == "" || s.backupMgr == nil {
return stk.HoldReason
}
if held, why := s.backupMgr.RestoreHoldForLang(stk.Name, lang); held && why != "" {
return why
}
return stk.HoldReason
},
"timeAgo": func(t time.Time) string {
if t.IsZero() {
return "–"
@@ -387,6 +376,13 @@ func (s *Server) localeFuncs(lang string) template.FuncMap {
// Found live on 2026-09-20: on an English app page the only Felhom-authored Hungarian left
// was this badge, „Naprakész", with a Hungarian tooltip under it.
"updateBadge": func(st stacks.Stack) *MetaBadge {
if st.HoldReason != "" { // R-625's twin — see updatebadge.go
return &MetaBadge{
Label: b.Msg(lang, "badge.update.held"),
Class: "tag-error",
Title: firstSentence(st.HoldReason),
}
}
switch compareInstalledToTemplate(st) {
case updateCurrent:
return &MetaBadge{
@@ -554,3 +550,43 @@ func (s *Server) note(key string, args ...interface{}) string {
// noteErr renders an error into a saved note in the box's language: its bundle message when it
// carries one (release B), its own text otherwise.
func (s *Server) noteErr(err error) string { return util.ErrText(s.boxLang(), err) }
// readerFuncs are the funcs that need the SERVER to answer in the reader's language, and — unlike
// localeFuncs — they apply to EVERY language, Hungarian included (R-647, v0.265.0). A hold sentence is
// composed by the backup side in one language at a time; before this a Hungarian reader of an English
// box (the `?lang=` switch, an operator) read it in English. On a Hungarian box a Hungarian reader gets
// exactly the bytes templateFuncMap gave (the parity fixtures are rendered through here).
// `prev` is the func set they wrap (templateFuncMap + localeFuncs for this language).
func (s *Server) readerFuncs(lang string, prev template.FuncMap) template.FuncMap {
holdFor := func(st stacks.Stack) string {
if st.HoldReason == "" || s.backupMgr == nil {
return st.HoldReason
}
if held, why := s.backupMgr.RestoreHoldForLang(st.Name, lang); held && why != "" {
return why
}
return st.HoldReason
}
out := template.FuncMap{
"holdText": func(st interface{}) string { return holdFor(stackOf(st)) },
"updateErrorText": func(st interface{}) string {
stk := stackOf(st)
if stk.UpdateErrorKey == stacks.UpdateErrorKeyHeld && s.backupMgr != nil {
if held, why := s.backupMgr.RestoreHoldForLang(stk.Name, lang); held && why != "" {
return why
}
}
return stk.UpdateErrorIn(lang)
},
}
if base, ok := prev["updateBadge"].(func(stacks.Stack) *MetaBadge); ok {
out["updateBadge"] = func(st stacks.Stack) *MetaBadge {
bd := base(st)
if bd != nil && st.HoldReason != "" { // R-625: the title is the hold's opening, for THIS reader
bd.Title = firstSentence(holdFor(st))
}
return bd
}
}
return out
}
@@ -0,0 +1,136 @@
package web
import (
"html"
"io"
"log"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// heldBehindStack is R-625's measured shape: HELD by a failed update, while the catalog has already
// published a newer version — the moment the page used to say „Frissítés elérhető".
func heldBehindStack(t *testing.T, s *Server, boxLang string) (stacks.Stack, *backup.Manager) {
t.Helper()
b := backup.NewManager(s.cfg, s.settings, log.New(io.Discard, "", 0))
s.backupMgr = b
if boxLang != "" {
if err := s.settings.SetLanguage(boxLang); err != nil {
t.Fatal(err)
}
}
at := time.Date(2026, 9, 23, 8, 0, 0, 0, time.UTC)
if err := b.HoldAfterFailedUpdateHolding("kimai", at, at.Add(-6*time.Hour), backup.UpdateTierLocal, b.UpdateCopyHolds("kimai", backup.UpdateTierLocal), ""); err != nil {
t.Fatal(err)
}
_, why := b.RestoreHoldFor("kimai") // in the BOX's language, as fillHoldReason stores it
st := i18nStack("kimai", "Kimai Time", stacks.StateStopped, true)
st.AppConfig = &stacks.AppConfig{Deployed: true, InstalledImages: map[string]stacks.InstalledImage{"web": {Ref: "kimai/kimai2:2.30.0"}}}
st.CatalogImages = map[string]string{"web": "kimai/kimai2:2.31.0"}
if compareInstalledToTemplate(st) != updateBehind {
t.Fatal("fixture: the held app must also be BEHIND the catalog (R-625's shape)")
}
st.HoldReason = why
st.UpdatePhase, st.UpdateErrorKey, st.UpdateError = stacks.UpdatePhaseFailed, stacks.UpdateErrorKeyHeld, why
return st, b
}
func renderHeld(t *testing.T, s *Server, st stacks.Stack, lang, tmpl string) string {
t.Helper()
c := i18nCase{"r625_" + tmpl, tmpl, func() map[string]interface{} {
if tmpl == "stacks" {
d := i18nLayoutData("stacks", "Alkalmazások")
d["Stacks"] = []stacks.Stack{st}
for _, k := range []string{"Subdomains", "MissingStorage", "NetworkStubs", "NetworkWarnings", "StorageLabels"} {
d[k] = map[string]string{}
}
return d
}
d := i18nLayoutData("stacks", "Kimai")
cp := st
d["Stack"], d["Meta"], d["AppInfo"] = &cp, cp.Meta, cp.Meta.AppInfo
return d
}}
return html.UnescapeString(renderI18nCase(t, s, lang, c))
}
// R-625 — a held app says it is stopped and needs a restore, in both languages, on the app page; and
// NEITHER page offers the Update button.
//
// COMPANION RED-PROOF (REPORT.md): remove the HoldReason branch from updateBadgeAt (v0.264.0's shape)
// — the Hungarian page says „Frissítés elérhető" and this fails.
func TestR625_AHeldAppSaysStoppedRestoreNeeded(t *testing.T) {
s := i18nTestServer(t)
st, _ := heldBehindStack(t, s, "")
for _, c := range []struct{ lang, badge, invite, title string }{
{"hu", "Megállítva — visszaállítás szükséges", "Frissítés elérhető", "A(z) kimai frissítése"},
{"en", "Stopped — restore needed", "Update available", "The update of kimai"},
} {
page := renderHeld(t, s, st, c.lang, "app_info")
if !strings.Contains(page, `<span class="tag tag-error" title="`) || !strings.Contains(page, c.badge) {
t.Errorf("%s app page: the held badge %q (tag-error) is missing", c.lang, c.badge)
}
if strings.Contains(page, c.invite) {
t.Errorf("%s app page still invites an update (%q) on a HELD app — R-625", c.lang, c.invite)
}
if i := strings.Index(page, c.badge); i > 0 && !strings.Contains(page[max(0, i-400):i], c.title) {
t.Errorf("%s: the badge title must open with the hold sentence (%q)", c.lang, c.title)
}
for _, tmpl := range []string{"app_info", "stacks"} {
if strings.Contains(renderHeld(t, s, st, c.lang, tmpl), `'kimai', 'update')`) {
t.Errorf("%s %s: the Update button is rendered for a HELD app", c.lang, tmpl)
}
}
}
// CONTROL: the same app, not held and running, DOES get the button on the list — so the check above
// can see a button when there is one.
free := st
free.HoldReason, free.UpdateError, free.UpdateErrorKey, free.State = "", "", "", stacks.StateRunning
if !strings.Contains(renderHeld(t, s, free, "hu", "stacks"), `'kimai', 'update')`) {
t.Fatal("control: a running, not-held app must show the Update button on the list")
}
}
// R-647 (1) — on an ENGLISH box, a Hungarian reader (`?lang=hu`, an operator) reads the hold and the
// held update's error in HUNGARIAN; the English reader reads English. Both ways, both pages.
//
// COMPANION RED-PROOF (REPORT.md): make readerFuncs return nil (v0.264.0: no Hungarian override) — the
// Hungarian page carries the English hold and this fails.
func TestR647_AReaderInTheOtherLanguageReadsTheHoldInTheirs(t *testing.T) {
s := i18nTestServer(t)
st, _ := heldBehindStack(t, s, "en")
if !strings.Contains(st.HoldReason, "The update of kimai") {
t.Fatalf("fixture: an English box must store the English hold, got %q", st.HoldReason)
}
const huWant, enWant = "A(z) kimai frissítése 2026-09-23 10:00-kor nem sikerült", "The update of kimai at 2026-09-23 10:00 did not succeed"
for _, tmpl := range []string{"app_info", "stacks"} {
hu := renderHeld(t, s, st, "hu", tmpl)
if !strings.Contains(hu, huWant) || strings.Contains(hu, enWant) {
t.Errorf("%s: the Hungarian reader of an English box must read the hold in Hungarian only", tmpl)
}
en := renderHeld(t, s, st, "en", tmpl)
if !strings.Contains(en, enWant) || strings.Contains(en, huWant) {
t.Errorf("%s: the English reader must read the hold in English only", tmpl)
}
}
}
// The held badge's Hungarian literal and the bundle say the same thing (the parity rule for funcmap).
func TestR625_HeldBadgeHungarianMatchesTheBundle(t *testing.T) {
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
st := stacks.Stack{Name: "x", HoldReason: "Egy. Kettő."}
if got, want := b.Msg("hu", "badge.update.held"), updateBadgeAt(st, time.Now()).Label; got != want {
t.Fatalf("badge.update.held: hu.json %q, funcmap %q", got, want)
}
if got := updateBadgeAt(st, time.Now()).Title; got != "Egy." {
t.Fatalf("the title is the first sentence, got %q", got)
}
}
+3
View File
@@ -310,6 +310,9 @@ func (s *Server) loadTemplates() {
for name, fn := range s.localeFuncs(lang) {
funcs[name] = fn
}
for name, fn := range s.readerFuncs(lang, funcs) {
funcs[name] = fn
}
start := time.Now()
t, st, err := parseTemplateSet(b, lang, funcs)
took := time.Since(start)
@@ -177,7 +177,7 @@
<span class="stack-state-badge state-run">Fut</span>
<span class="tag tag-error" title="A frissítés megállt: nincs friss mentés.">Megállítva — visszaállítás szükséges</span>
<a href="https://paste.example.hu" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
<a href="/stacks/privatebin/logs" class="btn btn-sm btn-outline">Napló</a>
@@ -252,7 +252,7 @@
<span class="tag tag-neutral"><span class="dot"></span>Leállítva</span>
<span class="tag tag-error" title="A frissítés megállt, a mentés hiányzik.">Megállítva — visszaállítás szükséges</span>
+22
View File
@@ -2,6 +2,7 @@ package web
import (
"fmt"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
@@ -73,6 +74,18 @@ func compareInstalledToTemplate(s stacks.Stack) updateState {
// never the decision. A new branch here needs its twin there, and
// TestLocaleFuncsHungarianBundleMatchesFuncMap fails if hu.json and these literals disagree.
func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge {
// R-625 (v0.265.0). A HELD app is stopped, and the only way back is a restore — a newer catalog
// version does not lift the hold (`09` §6.1), so „Frissítés elérhető" beside a button that answers
// 409 was two verdicts disagreeing. One verdict, read by the badge and by the button (stacks.html
// renders no Update button while HoldReason is set), exactly as R-524 did it for Ahead. True for
// both hold kinds: a failed restore holds the app stopped too, and the way back is also a restore.
if s.HoldReason != "" {
return &MetaBadge{
Label: "Megállítva — visszaállítás szükséges",
Class: "tag-error",
Title: firstSentence(s.HoldReason),
}
}
switch compareInstalledToTemplate(s) {
case updateCurrent:
return &MetaBadge{
@@ -120,3 +133,12 @@ func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge {
//
// It is INFORMATION ONLY. It is wired to no action, and the Frissítés button is untouched.
func updateBadge(s stacks.Stack) *MetaBadge { return updateBadgeAt(s, time.Now().UTC()) }
// firstSentence is the hold sentence's opening clause, for a badge title (R-625): up to and including
// the first full stop that ends a sentence; the whole text when it has none.
func firstSentence(s string) string {
if i := strings.Index(s, ". "); i >= 0 {
return s[:i+1]
}
return s
}