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

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-25 11:19:27 +02:00
parent 71accca427
commit 44ae4dea70
21 changed files with 376 additions and 10 deletions
@@ -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 {
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 {