controller v0.265.0: R-634 cause fixed, held apps say so, OOM storm alarm, R-647 leftovers
gates / gates (push) Successful in 27s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user