controller v0.272.0: the backup page says when a whole-box backup does not fit (R-685); R-671, R-670, R-677
gates / gates (push) Successful in 26s
gates / gates (push) Successful in 26s
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:
@@ -740,6 +740,11 @@ One summary line per night (`[update-leg] update leg (after-offsite): done=… u
|
||||
skipped=… [skipped: app=reason, …]`) in the log and in the hub report's `update_leg`. After `done`/`undone` the
|
||||
app's steps-left and badge are fresh at once (R-678). `stacks.update_window` is removed (it was never read).
|
||||
|
||||
**Leftovers (v0.272.0).** The backup page says in plain words when a whole-box backup was skipped because it
|
||||
cannot fit (the agent's numbers; R-685). A restore that lifts an update hold removes that hold's undo copies
|
||||
(R-671). Probe-only copies of `.felhom.yml` load without the backup-block check (no false ERROR on an undo, R-670).
|
||||
A re-tested floating tag's badge age counts from its test (R-677).
|
||||
|
||||
**Start/restart never answer "completed" (v0.263.0, R-642)** — they answer what was requested and the
|
||||
state the containers are in at that moment; whether the app works is the health probe's to say.
|
||||
|
||||
|
||||
@@ -589,6 +589,8 @@ func main() {
|
||||
// updated, not only once it is held — found live in Scenario F, see backup.Manager.isHeld.
|
||||
if backupMgr != nil {
|
||||
backupMgr.SetUpdatingCheck(stackMgr.IsUpdating)
|
||||
// R-671 (v0.272.0): a restore that lifts an update hold removes the undo copies that hold kept.
|
||||
backupMgr.SetUndoCopyRemover(stackMgr.RemoveUndoCopies)
|
||||
}
|
||||
if n := stackMgr.ResumeInterruptedUpdates(ctx); n > 0 {
|
||||
logger.Printf("[WARN] [update] resumed %d interrupted update(s)", n)
|
||||
|
||||
@@ -71,3 +71,12 @@ func TestUpdateLegIsWiredAtStartup(t *testing.T) {
|
||||
t.Error("the offbox-backup job must run through chainUpdateLeg")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoCopyRemoverIsWiredAtStartup — R-671's seam is CALLED from main.go (AST walk).
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the SetUndoCopyRemover call — this fails.
|
||||
func TestUndoCopyRemoverIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetUndoCopyRemover"]) == 0 {
|
||||
t.Fatal("main.go never calls SetUndoCopyRemover — a lifted hold's undo copies would stay for ever")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,9 @@ type Manager struct {
|
||||
|
||||
// updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck.
|
||||
updatingCheck func(stackName string) bool
|
||||
// undoCopyRemover (R-671, v0.272.0) deletes an app's leftover undo copies — stacks.Manager.RemoveUndoCopies,
|
||||
// wired in main.go (SetUndoCopyRemover). nil-safe: without it the copies stay, as before.
|
||||
undoCopyRemover func(stackName string) int
|
||||
|
||||
// R-475 update-precondition seams, one per tier. Nil → the real Tier2UnitRestorePoint /
|
||||
// ListRestorePoints / OffsiteSnapshotTimes. They let a test reach Tier 1 and Tier 3 without a drive
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-671 (v0.272.0) — the undo copies kept by an update hold are removed when a restore lifts that hold; a
|
||||
// RESTORE hold (R-379) lifts nothing and removes nothing; an app a guarded update is moving is never touched.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the undoCopyRemover call from clearUpdateHoldAfterRestore — this test
|
||||
// fails at "the lifted update hold's undo copies were left behind".
|
||||
func TestR671_RestoreThatClearsAnUpdateHoldRemovesItsUndoCopies(t *testing.T) {
|
||||
sett := slice4Settings(t)
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
|
||||
removed := map[string]int{}
|
||||
m.SetUndoCopyRemover(func(s string) int { removed[s]++; return 3 })
|
||||
_ = m.HoldAfterFailedUpdate("nextcloud", time.Now(), time.Now(), UpdateTierLocal)
|
||||
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "rst", At: "2026-08-22T14:00:00Z"})
|
||||
m.clearUpdateHoldAfterRestore("nextcloud")
|
||||
m.clearUpdateHoldAfterRestore("rst")
|
||||
if removed["nextcloud"] != 1 {
|
||||
t.Fatalf("the lifted update hold's undo copies were left behind (remover calls: %v)", removed)
|
||||
}
|
||||
if removed["rst"] != 0 {
|
||||
t.Fatal("a restore hold is not lifted by a restore, so nothing of it may be removed")
|
||||
}
|
||||
// an app mid-update: its copies are the live undo — never removed
|
||||
_ = m.HoldAfterFailedUpdate("moving", time.Now(), time.Now(), UpdateTierLocal)
|
||||
m.SetUpdatingCheck(func(s string) bool { return s == "moving" })
|
||||
m.clearUpdateHoldAfterRestore("moving")
|
||||
if removed["moving"] != 0 {
|
||||
t.Fatal("the undo copies of an app a guarded update is moving must never be removed")
|
||||
}
|
||||
}
|
||||
@@ -607,6 +607,21 @@ func (m *Manager) clearUpdateHoldAfterRestore(stackName string) {
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] %s: restore completed — the update hold (set %s) is CLEARED", stackName, h.At)
|
||||
// R-671 (v0.272.0): the undo copies the hold kept describe the state this restore just replaced. They
|
||||
// were kept so the hold's data stayed recoverable; once the app is restored whole they are dead weight
|
||||
// (measured 2026-09-24 on 9202: three nextcloud copies, ~0.9 GiB, outlived the hold and nothing named
|
||||
// them). Removed here, and only here — never for a restore hold (R-379), never while an update moves the
|
||||
// app. Pinned by TestR671_RestoreThatClearsAnUpdateHoldRemovesItsUndoCopies.
|
||||
if m.undoCopyRemover != nil && (m.updatingCheck == nil || !m.updatingCheck(stackName)) {
|
||||
n := m.undoCopyRemover(stackName)
|
||||
m.logger.Printf("[INFO] [backup] %s: removed %d undo cop(y/ies) the lifted update hold had kept (R-671)", stackName, n)
|
||||
}
|
||||
}
|
||||
|
||||
// SetUndoCopyRemover wires the undo-copy cleanup (R-671): stacks.Manager.RemoveUndoCopies. INIT-ONLY, main.go —
|
||||
// pinned by TestUndoCopyRemoverIsWiredAtStartup (an AST walk). The backup package cannot import stacks.
|
||||
func (m *Manager) SetUndoCopyRemover(fn func(stackName string) int) {
|
||||
m.undoCopyRemover = fn
|
||||
}
|
||||
|
||||
// UpdateHeldStacks is the set of apps held stopped after a failed update (R-660, v0.268.0) — the
|
||||
|
||||
@@ -2412,5 +2412,7 @@
|
||||
"settings.app_update_on": "Automatic app updates are on. This applies from the next night.",
|
||||
"settings.app_update_off": "Automatic app updates are off. From the next night no app updates by itself; you can still update them by hand.",
|
||||
"settings.app_update_save_error": "The setting could not be saved. Try again.",
|
||||
"app_info.auto_update_done": "Automatic update at %s — done."
|
||||
"app_info.auto_update_done": "Automatic update at %s — done.",
|
||||
"backup.tier.no_space": "The full system backup does not fit: it needs %s and %s is free. Keeping fewer old backups, or a bigger disk, fixes it.",
|
||||
"backup.tier.no_space_unknown": "The full system backup does not fit on the disk. Keeping fewer old backups, or a bigger disk, fixes it."
|
||||
}
|
||||
|
||||
@@ -2400,5 +2400,7 @@
|
||||
"settings.app_update_on": "Az alkalmazások automatikus frissítése bekapcsolva. A következő éjjel már érvényes.",
|
||||
"settings.app_update_off": "Az alkalmazások automatikus frissítése kikapcsolva. A következő éjjel már egyetlen alkalmazás sem frissül magától; kézzel továbbra is frissítheted őket.",
|
||||
"settings.app_update_save_error": "A beállítást nem sikerült elmenteni. Próbáld újra.",
|
||||
"app_info.auto_update_done": "Automatikus frissítés %s-kor — sikeres."
|
||||
"app_info.auto_update_done": "Automatikus frissítés %s-kor — sikeres.",
|
||||
"backup.tier.no_space": "A teljes rendszermentés nem fér el: %s kell, %s szabad. Kevesebb régi mentés megtartása vagy nagyobb lemez segít.",
|
||||
"backup.tier.no_space_unknown": "A teljes rendszermentés nem fér el a lemezen. Kevesebb régi mentés megtartása vagy nagyobb lemez segít."
|
||||
}
|
||||
|
||||
@@ -225,5 +225,5 @@ func loadMetadataFile(path string) (Metadata, error) {
|
||||
if err := os.WriteFile(filepath.Join(tmp, ".felhom.yml"), data, 0o600); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
return LoadMetadata(tmp), nil
|
||||
return LoadProbeMetadata(tmp), nil // R-670
|
||||
}
|
||||
|
||||
@@ -316,7 +316,21 @@ func (m Metadata) CatalogSinceAge(now time.Time) (int, bool) {
|
||||
|
||||
// LoadMetadata reads .felhom.yml from a stack directory.
|
||||
// Returns default metadata if the file doesn't exist.
|
||||
func LoadMetadata(stackDir string) Metadata {
|
||||
func LoadMetadata(stackDir string) Metadata { return loadMetadata(stackDir, true) }
|
||||
|
||||
// LoadProbeMetadata reads a `.felhom.yml` for its health check and resources ONLY (R-670, v0.272.0): the
|
||||
// backup and data_paths blocks are dropped instead of validated. For the copies that live without their
|
||||
// compose file — the undo's `pre-update-meta/` and a step's `steps/<key>.felhom.yml` — the validation could
|
||||
// only fail ("docker-compose.yml unreadable") and logged a false ERROR on every undo. Nothing read through
|
||||
// this loader may consult Backup or DataPaths; they are always nil here.
|
||||
// Pinned by TestR670_ProbeLoaderLogsNoFalseError.
|
||||
func LoadProbeMetadata(stackDir string) Metadata {
|
||||
m := loadMetadata(stackDir, false)
|
||||
m.Backup, m.DataPaths = nil, nil
|
||||
return m
|
||||
}
|
||||
|
||||
func loadMetadata(stackDir string, validateBackup bool) Metadata {
|
||||
meta := Metadata{}
|
||||
|
||||
path := filepath.Join(stackDir, ".felhom.yml")
|
||||
@@ -401,7 +415,7 @@ func LoadMetadata(stackDir string) Metadata {
|
||||
// block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades
|
||||
// to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes
|
||||
// meta.Backup yet (Task 3/4).
|
||||
if meta.Backup != nil || len(meta.DataPaths) > 0 {
|
||||
if validateBackup && (meta.Backup != nil || len(meta.DataPaths) > 0) {
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
binds := ParseComposeClassifiableBinds(composePath)
|
||||
_, composeErr := os.Stat(composePath)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-670 (v0.272.0) — every undo logged `[ERROR] .felhom.yml backup block rejected … docker-compose.yml
|
||||
// unreadable`, because the undo read the previous version's `.felhom.yml` from `pre-update-meta/`, which has
|
||||
// no compose beside it, through the validating loader. The consequence asserted: a REAL undo of an app whose
|
||||
// `.felhom.yml` carries a backup block writes no such line to the process log.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): read PrevMeta with LoadMetadata again — this test fails at "the undo logged
|
||||
// a false backup-block ERROR".
|
||||
func TestR670_ProbeLoaderLogsNoFalseError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(prev)
|
||||
|
||||
m, dir, _, _, _ := ladderManager(t, true)
|
||||
withBlock := "display_name: Nextcloud\nbackup:\n userdata:\n - path: data\n class: excluded\n"
|
||||
mustWrite(t, filepath.Join(dir, ".felhom.yml"), withBlock)
|
||||
if err := os.MkdirAll(filepath.Join(dir, appliedMetaDir), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, appliedMetaDir, ".felhom.yml"), withBlock)
|
||||
m.undoCopier = newFakeCopier(map[string]string{undoVol: "OLD"})
|
||||
m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return false, "B unhealthy" }
|
||||
m.updateUndoHealthFn = func(context.Context, string, time.Duration, *Metadata) (bool, string) { return true, "A answers" }
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseUndone {
|
||||
t.Fatalf("the scenario must reach an undo; ended %q", st.UpdatePhase)
|
||||
}
|
||||
if strings.Contains(buf.String(), "docker-compose.yml unreadable") {
|
||||
t.Fatalf("the undo logged a false backup-block ERROR:\n%s", buf.String())
|
||||
}
|
||||
// control: the validating loader on the same compose-less copy DOES log it (the line is real elsewhere)
|
||||
buf.Reset()
|
||||
LoadMetadata(filepath.Join(dir, preUpdateMetaDirForTest(t, dir, withBlock)))
|
||||
if !strings.Contains(buf.String(), "docker-compose.yml unreadable") {
|
||||
t.Fatal("control failed: LoadMetadata on a compose-less copy must still reject the block")
|
||||
}
|
||||
if pm := LoadProbeMetadata(filepath.Join(dir, "probe-copy")); pm.Backup != nil || pm.DataPaths != nil {
|
||||
t.Fatal("the probe loader must never hand out a backup block")
|
||||
}
|
||||
}
|
||||
|
||||
// preUpdateMetaDirForTest writes a compose-less copy and returns its dir name (relative to dir).
|
||||
func preUpdateMetaDirForTest(t *testing.T, dir, body string) string {
|
||||
t.Helper()
|
||||
d := filepath.Join(dir, "probe-copy")
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(d, ".felhom.yml"), body)
|
||||
return "probe-copy"
|
||||
}
|
||||
@@ -537,7 +537,7 @@ func (m *Manager) tryUndo(ctx context.Context, name, dir, why string, entry *upd
|
||||
meta := LoadMetadata(dir)
|
||||
if entry.PrevMeta != "" {
|
||||
if _, err := os.Stat(filepath.Join(entry.PrevMeta, ".felhom.yml")); err == nil {
|
||||
meta = LoadMetadata(entry.PrevMeta)
|
||||
meta = LoadProbeMetadata(entry.PrevMeta) // R-670: a probe copy, no compose beside it
|
||||
} else {
|
||||
m.logger.Printf("[WARN] [stacks] update %s: the previous .felhom.yml is missing (%v) — checking with the current one", name, err)
|
||||
}
|
||||
|
||||
@@ -210,3 +210,35 @@ func parseImageTag(tag string) (util.Version, string, bool) {
|
||||
}
|
||||
return v, suffix, true
|
||||
}
|
||||
|
||||
// BehindSinceAge is the "Frissítés elérhető — N napja" age, in calendar days (R-677, v0.272.0). For a
|
||||
// DIGEST-ONLY move — every installed ref equals the catalog's and only a newer TESTED digest makes the app
|
||||
// behind (a floating tag re-tested) — the age counts from when that digest was tested (the ladder entry's
|
||||
// tested_at), not from the template's `catalog_since`, which dates the TAG and read „1 napja" for a digest
|
||||
// tested minutes earlier (seen 2026-09-24). Every other case keeps CatalogSinceAge. Both badge producers
|
||||
// call this, so they cannot disagree. Pinned by TestR677_DigestOnlyAgeFromTestedAt.
|
||||
func BehindSinceAge(s Stack, now time.Time) (int, bool) {
|
||||
if digestOnlyBehind(s) && !s.CatalogTestedAt.IsZero() {
|
||||
t := s.CatalogTestedAt.UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
if d := int(today.Sub(day).Hours() / 24); d >= 0 {
|
||||
return d, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return s.Meta.CatalogSinceAge(now)
|
||||
}
|
||||
|
||||
// digestOnlyBehind: every catalog service's ref equals the installed ref, and a newer tested digest exists.
|
||||
func digestOnlyBehind(s Stack) bool {
|
||||
if s.AppConfig == nil || len(s.CatalogImages) == 0 || len(s.AppConfig.InstalledImages) != len(s.CatalogImages) {
|
||||
return false
|
||||
}
|
||||
for svc, want := range s.CatalogImages {
|
||||
if got, ok := s.AppConfig.InstalledImages[svc]; !ok || got.Ref != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return digestBehind(s)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -136,6 +137,28 @@ type guestTierView struct {
|
||||
Current bool // newest success is inside the tier's window
|
||||
FailedAfter bool // the last attempt failed and is newer than the newest success
|
||||
FailedAt time.Time
|
||||
// NoSpaceLine (v0.272.0, R-685 page half) is the plain sentence under a failed tier when the agent
|
||||
// SKIPPED the backup because it cannot fit (agent v0.134.0+), in the reader's language.
|
||||
NoSpaceLine string
|
||||
}
|
||||
|
||||
// agentNoSpacePrefix is the agent's `backup.BackupSkipNoSpacePrefix` (felhom-agent v0.134.0,
|
||||
// internal/backup/runner.go) — the stable start of a tier attempt's Error when the space preflight refused.
|
||||
// A wire contract between two repos: change it in both or in neither.
|
||||
const agentNoSpacePrefix = "skipped: not enough space: "
|
||||
|
||||
// agentNoSpaceRe reads the agent's sentence: "<target> has X GiB free; … needs about Z GiB (…)".
|
||||
var agentNoSpaceRe = regexp.MustCompile(`has ([0-9.]+ GiB) free;.*needs about ([0-9.]+ GiB)`)
|
||||
|
||||
// noSpaceLine renders the household's sentence for a space skip, "" when the attempt is not one.
|
||||
func noSpaceLine(errText string, msg func(key string, a ...interface{}) string) string {
|
||||
if !strings.HasPrefix(errText, agentNoSpacePrefix) {
|
||||
return ""
|
||||
}
|
||||
if m := agentNoSpaceRe.FindStringSubmatch(errText); m != nil {
|
||||
return msg("backup.tier.no_space", m[2], m[1])
|
||||
}
|
||||
return msg("backup.tier.no_space_unknown")
|
||||
}
|
||||
|
||||
// tierWindow is how old a tier's newest success may be and still count as current: its cadence plus
|
||||
@@ -174,6 +197,7 @@ func buildTierViews(v *guestBackupView, tiers []agentapi.TierBackupState, cadenc
|
||||
if a := t.LastAttempt; a != nil && !a.Success {
|
||||
if ts, err := time.Parse(time.RFC3339, a.StartedAt); err == nil && (!tv.HasSuccess || ts.After(tv.SuccessAt)) {
|
||||
tv.FailedAfter, tv.FailedAt = true, ts
|
||||
tv.NoSpaceLine = noSpaceLine(a.Error, msg)
|
||||
}
|
||||
}
|
||||
if !tv.NotSetUp && !tv.Current {
|
||||
|
||||
@@ -399,7 +399,7 @@ func (s *Server) localeFuncs(lang string) template.FuncMap {
|
||||
}
|
||||
case updateBehind:
|
||||
label := b.Msg(lang, "badge.update.behind")
|
||||
if days, ok := st.Meta.CatalogSinceAge(time.Now().UTC()); ok {
|
||||
if days, ok := stacks.BehindSinceAge(st, time.Now().UTC()); ok { // R-677
|
||||
if days == 0 {
|
||||
label += b.Msg(lang, "badge.update.behind.today")
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-677 (v0.272.0) — a floating tag re-tested at a new digest: the badge's age must count from when that
|
||||
// digest was TESTED (today), not from the template's catalog_since (the tag's date, 3 days ago). Both badge
|
||||
// producers — the Hungarian literal one and the per-language one — are asserted, so they cannot drift.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make stacks.BehindSinceAge return CatalogSinceAge always — this test fails
|
||||
// at "the badge dates the TAG, not the tested digest".
|
||||
func TestR677_DigestOnlyAgeFromTestedAt(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
|
||||
dA := "sha256:" + strings.Repeat("a", 64)
|
||||
dB := "sha256:" + strings.Repeat("b", 64)
|
||||
st := stacks.Stack{Name: "redisapp", Deployed: true,
|
||||
Meta: stacks.Metadata{CatalogSince: "2026-09-22"},
|
||||
CatalogImages: map[string]string{"web": "redis:7-alpine"},
|
||||
CatalogDigests: map[string]string{"web": dB},
|
||||
CatalogTestedAt: now.Add(-30 * time.Minute),
|
||||
AppConfig: &stacks.AppConfig{InstalledImages: map[string]stacks.InstalledImage{
|
||||
"web": {Ref: "redis:7-alpine", Digest: dA, At: "2026-09-21T00:00:00Z"}}}}
|
||||
if stacks.CatalogOrder(st) != stacks.UpdateOrderBehind {
|
||||
t.Fatal("fixture must read Behind (a newer tested digest)")
|
||||
}
|
||||
if d, ok := stacks.BehindSinceAge(st, now); !ok || d != 0 {
|
||||
t.Fatalf("the badge dates the TAG, not the tested digest: got %d days (ok=%v), want 0", d, ok)
|
||||
}
|
||||
if b := updateBadgeAt(st, now); b == nil || !strings.HasSuffix(b.Label, "— ma") {
|
||||
t.Fatalf("Hungarian producer: %+v, want „… — ma\"", b)
|
||||
}
|
||||
// the per-language producer, on the real clock
|
||||
live := st
|
||||
live.CatalogTestedAt = time.Now().UTC().Add(-time.Minute)
|
||||
live.Meta.CatalogSince = time.Now().UTC().AddDate(0, 0, -3).Format("2006-01-02")
|
||||
en := testPageServer(t).localeFuncs("en")["updateBadge"].(func(stacks.Stack) *MetaBadge)(live)
|
||||
if en == nil || !strings.HasSuffix(en.Label, "today") {
|
||||
t.Fatalf("English producer: %+v, want \"… — today\"", en)
|
||||
}
|
||||
// a REAL version move keeps the template's date
|
||||
mv := st
|
||||
mv.CatalogImages = map[string]string{"web": "redis:7.4-alpine"}
|
||||
if d, ok := stacks.BehindSinceAge(mv, now); !ok || d != 3 {
|
||||
t.Fatalf("a version move must keep catalog_since's age (3), got %d (ok=%v)", d, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// R-685 page half (v0.272.0) — when the agent (v0.134.0+) SKIPPED a whole-box backup because it cannot fit,
|
||||
// the backup page says so in plain words, in the reader's language, with the numbers the agent measured.
|
||||
// Per state: a space skip (the agent's exact sentence, taken from the live proof 2026-09-24), a space skip
|
||||
// whose numbers cannot be read, an ordinary failure (no line), and a success (no line); rendered through the
|
||||
// REAL backups template.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): drop `tv.NoSpaceLine = noSpaceLine(...)` — this test fails at "a space skip
|
||||
// must be said on the page".
|
||||
func TestR685_BackupPageSaysTheBackupDoesNotFit(t *testing.T) {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 9, 25, 9, 0, 0, 0, time.UTC)
|
||||
live := "skipped: not enough space: local has 14.9 GiB free; the last archive of guest 9201 was 7.6 GiB, so a new one needs about 11.3 GiB (old archives are removed only after a successful backup)"
|
||||
tiers := func(errText string, ok bool) []agentapi.TierBackupState {
|
||||
return []agentapi.TierBackupState{{Target: "local", Primary: true, Storage: "present",
|
||||
LastSuccess: &agentapi.BackupRecord{StartedAt: now.Add(-50 * time.Hour).Format(time.RFC3339), SizeBytes: 8 << 30},
|
||||
LastAttempt: &agentapi.TierAttempt{StartedAt: now.Add(-2 * time.Hour).Format(time.RFC3339), Success: ok, Error: errText}}}
|
||||
}
|
||||
cases := []struct {
|
||||
name, err string
|
||||
ok bool
|
||||
want map[string]string // lang → substring; "" = no line
|
||||
}{
|
||||
{"space skip", live, false, map[string]string{
|
||||
"hu": "A teljes rendszermentés nem fér el: 11.3 GiB kell, 14.9 GiB szabad.",
|
||||
"en": "The full system backup does not fit: it needs 11.3 GiB and 14.9 GiB is free."}},
|
||||
{"space skip, unreadable numbers", "skipped: not enough space: something new", false, map[string]string{
|
||||
"hu": "A teljes rendszermentés nem fér el a lemezen.", "en": "The full system backup does not fit on the disk."}},
|
||||
{"ordinary failure", "vzdump failed: job errors", false, map[string]string{"hu": "", "en": ""}},
|
||||
{"success", "", true, map[string]string{"hu": "", "en": ""}},
|
||||
}
|
||||
s := securityHarness(t)
|
||||
s.loadTemplates()
|
||||
for _, c := range cases {
|
||||
for _, lang := range []string{"hu", "en"} {
|
||||
msg := func(key string, a ...interface{}) string {
|
||||
if len(a) == 0 {
|
||||
return b.Msg(lang, key)
|
||||
}
|
||||
return b.Msgf(lang, key, a...)
|
||||
}
|
||||
v := &guestBackupView{Available: true}
|
||||
buildTierViews(v, tiers(c.err, c.ok), map[string]int64{"local": 86400}, now, msg)
|
||||
got := v.Tiers[0].NoSpaceLine
|
||||
if c.want[lang] == "" {
|
||||
if got != "" {
|
||||
t.Errorf("%s [%s]: no sentence expected, got %q", c.name, lang, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(got, c.want[lang]) {
|
||||
t.Fatalf("%s [%s]: a space skip must be said on the page — got %q, want %q", c.name, lang, got, c.want[lang])
|
||||
}
|
||||
if lang == "hu" {
|
||||
var buf bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&buf, "backups", map[string]interface{}{"Page": "backups", "Title": "t", "GuestBackup": v, "Backup": map[string]interface{}{"Enabled": true, "DumpFiles": []interface{}{}}}); err != nil {
|
||||
t.Fatalf("the backups page did not render: %v", err)
|
||||
}
|
||||
out := html.UnescapeString(buf.String())
|
||||
if !strings.Contains(out, c.want[lang]) || !strings.Contains(out, `data-no-space="true"`) {
|
||||
i := strings.Index(out, "backup-tier-table"); t.Fatalf("%s: the rendered page does not carry the sentence; near table: %q", c.name, out[max(0, i):min(len(out), i+1500)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@
|
||||
<div>{{T "backups.meg_nincs_sikeres_mentes"}} <span class="tag tag-warn" style="margin-left:.4rem">{{T "backups.esedekes"}}</span></div>
|
||||
{{end}}
|
||||
{{if .FailedAfter}}
|
||||
<div style="color:var(--red);margin-top:.25rem">{{T "backups.sikertelen"}}</div>
|
||||
<div style="color:var(--red);margin-top:.25rem"{{if .NoSpaceLine}} data-no-space="true"{{end}}>{{T "backups.sikertelen"}}{{if .NoSpaceLine}} — {{.NoSpaceLine}}{{end}}</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
|
||||
@@ -107,7 +107,7 @@ func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge {
|
||||
}
|
||||
case updateBehind:
|
||||
label := "Frissítés elérhető"
|
||||
if days, ok := s.Meta.CatalogSinceAge(now); ok {
|
||||
if days, ok := stacks.BehindSinceAge(s, now); ok { // R-677
|
||||
if days == 0 {
|
||||
label += " — ma"
|
||||
} else {
|
||||
|
||||
@@ -79,7 +79,9 @@
|
||||
"app_info.auto_update_done": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go.",
|
||||
"settings.app_update_on": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go.",
|
||||
"settings.app_update_off": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go.",
|
||||
"settings.app_update_save_error": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go."
|
||||
"settings.app_update_save_error": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go.",
|
||||
"backup.tier.no_space": "BORN AS A KEY, v0.272.0 (R-685 page half) -- a NEW sentence, never a Go literal. Pinned by internal/web/r685_no_space_test.go.",
|
||||
"backup.tier.no_space_unknown": "BORN AS A KEY, v0.272.0 (R-685 page half) -- a NEW sentence, never a Go literal. Pinned by internal/web/r685_no_space_test.go."
|
||||
},
|
||||
"flash.share.already_on": "A megosztás már be van kapcsolva.",
|
||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||
|
||||
Reference in New Issue
Block a user