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
+1 -1
View File
@@ -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
}
+16 -2
View File
@@ -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"
}
+1 -1
View File
@@ -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)
}
+32
View File
@@ -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)
}