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:
@@ -724,6 +724,16 @@ func (m *Manager) runVolumeDumps() (summary []string, dumped int, allOK bool) {
|
||||
continue
|
||||
}
|
||||
|
||||
// R-634 (v0.265.0): an app whose deploy is still running is NOT an installation to back up,
|
||||
// and stopping it is what broke the deploy. The list is taken once at the start of the run, so
|
||||
// the question is asked again here, immediately before the stop. A SKIP, not a failure: there
|
||||
// is nothing of the household's in it yet, and a FAIL would page the operator for a deploy.
|
||||
if m.stackIsDeploying(stack.Name) {
|
||||
m.logger.Printf("[INFO] [backup] Skipping volume dump for %s — the app is still being deployed (R-634)", stack.Name)
|
||||
summary = append(summary, fmt.Sprintf("SKIP %s volumes (deploying)", stack.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
// R-181: the reserve, ahead of DumpAppVolumesSafe so a refused app is NOT stopped. For an app
|
||||
// that already has a DB this is a memo lookup taken before its DB dump; for a volume-only app
|
||||
// this is where its verdict is taken, still before its first byte.
|
||||
@@ -1398,3 +1408,17 @@ func (m *Manager) 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 (m *Manager) noteErr(err error) string { return util.ErrText(m.boxLang(), err) }
|
||||
|
||||
// deployingReporter is the OPTIONAL half of the stack provider that can say a deploy is in flight
|
||||
// (R-634). Optional on purpose: the production adapter implements it, and the many test fakes that
|
||||
// predate it need not — a provider that cannot answer is read as "not deploying", today's behaviour.
|
||||
type deployingReporter interface {
|
||||
IsDeploying(name string) bool
|
||||
}
|
||||
|
||||
func (m *Manager) stackIsDeploying(name string) bool {
|
||||
if d, ok := m.stackProvider.(deployingReporter); ok {
|
||||
return d.IsDeploying(name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// R-634 (v0.265.0). Measured 2026-09-23 on 9202: a whole-box backup pressed 20 s into outline's
|
||||
// deploy stopped it (`compose down`), dumped its half-made volumes and ran a SECOND `compose up -d`
|
||||
// beside the deploy's own — both failed, and the deploy recorded „not deployed".
|
||||
|
||||
type deployingFake struct {
|
||||
volDumpFakeProvider
|
||||
deploying map[string]bool
|
||||
}
|
||||
|
||||
func (f *deployingFake) IsDeploying(name string) bool { return f.deploying[name] }
|
||||
|
||||
// Through the dumpVolumesSafe SEAM, never the real dump: the real one runs `docker run … tar` against a
|
||||
// named volume, which on a developer machine CREATES that volume (it did, once, on DooPlex while this
|
||||
// test was written). The seam is where the stop happens, so a call to it IS the leak.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): delete the stackIsDeploying block in runVolumeDumps — the dump
|
||||
// (and with it the stop) is called for outline and this fails with dumped=[outline].
|
||||
func TestR634_VolumeLegNeverStopsADeployingApp(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys")
|
||||
fake := &deployingFake{
|
||||
volDumpFakeProvider: volDumpFakeProvider{
|
||||
stacks: []StackSummary{{Name: "outline"}},
|
||||
volumes: map[string][]string{"outline": {"outline_outline_data"}},
|
||||
},
|
||||
deploying: map[string]bool{"outline": true},
|
||||
}
|
||||
m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0), systemDataPath: cfg.Paths.SystemDataPath,
|
||||
stackProvider: fake}
|
||||
var calls []string
|
||||
m.dumpVolumesSafe = func(name string) error { calls = append(calls, name); return nil }
|
||||
|
||||
summary, dumped, ok := m.runVolumeDumps()
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("a DEPLOYING app was stopped and dumped by the backup: dumped=%v (R-634's race)", calls)
|
||||
}
|
||||
if !ok || dumped != 0 {
|
||||
t.Errorf("a deploy in flight is a SKIP, never a failure that pages the operator: ok=%v dumped=%d %v", ok, dumped, summary)
|
||||
}
|
||||
if !containsSummary(summary, "SKIP outline volumes (deploying)") {
|
||||
t.Errorf("the skip must be visible in the run summary, got %v", summary)
|
||||
}
|
||||
|
||||
// Control: the same app once its deploy has finished IS backed up (the skip is conditional).
|
||||
fake.deploying["outline"] = false
|
||||
m.runVolumeDumps()
|
||||
if len(calls) != 1 || calls[0] != "outline" {
|
||||
t.Errorf("control: a finished app must be dumped as before, dumped=%v", calls)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// v0.263.0 — a hold after a FAILED UNDO opens with what was tried and what state the data is in, in the
|
||||
@@ -65,3 +69,15 @@ func TestUndo_HoldSentenceSaysTheUndoWasTriedAndTheDataState(t *testing.T) {
|
||||
t.Errorf("no Hungarian may remain on an English box, got %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// R-647 (2) — the key form of the copy verdict is a bundle KEY, the same verdict as the phrase.
|
||||
func TestR647_UpdateCopyHoldsKeyIsTheKeyOfThePhrase(t *testing.T) {
|
||||
m := NewManager(&config.Config{}, nil, log.New(io.Discard, "", 0))
|
||||
k := m.UpdateCopyHoldsKey("x", UpdateTierLocal)
|
||||
if !strings.HasPrefix(k, "hold.copy_holds.") {
|
||||
t.Fatalf("want a hold.copy_holds.* key, got %q", k)
|
||||
}
|
||||
if util.Text(i18n.Default, k) != m.UpdateCopyHolds("x", UpdateTierLocal) {
|
||||
t.Fatal("the key must render to exactly the phrase UpdateCopyHolds gives")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,13 @@ func (m *Manager) UpdateCopyHolds(stackName string, tier int) string {
|
||||
return util.Text(i18n.Default, updateCopyHoldsKey(m.DataOutsideUnit(stackName), tier))
|
||||
}
|
||||
|
||||
// UpdateCopyHoldsKey is the same verdict as UpdateCopyHolds, as its bundle KEY (R-647, v0.265.0). The
|
||||
// app_update_held event carries the key, not the Hungarian phrase: the hub prints the raw details as
|
||||
// the mail's `Note:` line, and an English household read „a beállításokat, …" there.
|
||||
func (m *Manager) UpdateCopyHoldsKey(stackName string, tier int) string {
|
||||
return updateCopyHoldsKey(m.DataOutsideUnit(stackName), tier)
|
||||
}
|
||||
|
||||
func updateCopyHoldsKey(outside bool, tier int) string {
|
||||
switch tier {
|
||||
case UpdateTierLocal:
|
||||
|
||||
Reference in New Issue
Block a user