controller v0.269.0: whole restore from the second drive; crash loops stopped; exact image digests; steps judged by their own .felhom.yml (decisions 26-28, R-661 R-666 R-667 R-668 R-664 R-665 R-662, 09 6.4 part 6)
gates / gates (push) Successful in 27s

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-24 12:18:39 +02:00
parent 7c3b3a9694
commit 3c6b49b31c
141 changed files with 3401 additions and 237 deletions
@@ -0,0 +1,42 @@
package sync
import (
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// v0.269.0 (`09` §6.4 part 6) — the syncer writes the catalog's compose WITH the tested digests of the
// ladder entry for exactly its refs, for an undeployed app and for a pinned app the catalog still matches;
// and the stored definition is refreshed with the same bytes.
//
// COMPANION RED-PROOF (REPORT): make RenderWithLadderDigests return its input — the image line stays a bare
// tag and this test fails at "no digest in the rendered compose".
func TestDigest_SyncerRendersTheTestedDigest(t *testing.T) {
s, stackDir, catDir := renderFixture(t, tplOld)
write(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Nextcloud\nupdate_ladder:\n"+
` - {"from": {"web": "nextcloud:30.0.0-apache"}, "to": {"web": "nextcloud:31.0.14-apache"}, "digest": {"web": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, "verdict": "proven", "tested_at": "2026-09-24T01:00:00Z"}`+"\n")
for _, plan := range []func(string) stacks.RenderPlan{
func(string) stacks.RenderPlan { return stacks.RenderPlan{} }, // not deployed
func(string) stacks.RenderPlan {
p := pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, false)("")
p.StackDir = stackDir
return p
},
} {
write(t, filepath.Join(stackDir, "docker-compose.yml"), "services: {}\n") // force a change each pass
s.SetRenderPlanFn(plan)
if _, _, err := s.copyTemplates(); err != nil {
t.Fatal(err)
}
got := readFile(t, filepath.Join(stackDir, "docker-compose.yml"))
if !strings.Contains(got, "image: nextcloud:31.0.14-apache@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") {
t.Fatalf("no digest in the rendered compose:\n%s", got)
}
}
if !strings.Contains(readFile(t, stacks.AppliedComposePath(stackDir)), "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") {
t.Fatal("the stored definition was not refreshed with the digest")
}
}
+30 -2
View File
@@ -386,7 +386,20 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
src, refreshAppliedIn = renderedSrc, refresh
}
changed, err := copyIfChanged(src, dst)
var changed bool
var err error
var rendered []byte // v0.269.0: the catalog compose with its TESTED digests (`09` §6.4 part 6)
if filename == "docker-compose.yml" && filepath.Dir(src) == srcDir {
raw, rerr := os.ReadFile(src)
if rerr != nil {
s.logger.Printf("[WARN] [sync] Failed to read catalog file %s/%s: %v", appName, filename, rerr)
continue
}
rendered = stacks.RenderWithLadderDigests(srcDir, raw)
changed, err = writeIfChanged(rendered, dst)
} else {
changed, err = copyIfChanged(src, dst)
}
if err != nil {
s.logger.Printf("[WARN] [sync] Failed to copy catalog file %s/%s: %v", appName, filename, err)
continue
@@ -401,7 +414,11 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
// The IMAGES are unchanged here by construction (this branch only runs when the
// catalog's images equal the pin), so no version moves and no intent is rewritten.
if refreshAppliedIn != "" {
if data, rerr := os.ReadFile(src); rerr != nil {
if rendered != nil {
if serr := stacks.StoreAppliedDefinition(refreshAppliedIn, rendered); serr != nil {
s.logger.Printf("[WARN] [sync] %s: could not refresh the stored definition: %v", appName, serr)
}
} else if data, rerr := os.ReadFile(src); rerr != nil {
s.logger.Printf("[WARN] [sync] %s: could not re-read the template to refresh its stored definition: %v", appName, rerr)
} else if serr := stacks.StoreAppliedDefinition(refreshAppliedIn, data); serr != nil {
s.logger.Printf("[WARN] [sync] %s: could not refresh the stored definition: %v", appName, serr)
@@ -541,6 +558,17 @@ func (s *Syncer) logFileHashes(appName, filename, src, dst string) {
s.logger.Printf("[DEBUG] [sync] %s/%s: src=%s, dst=%s (changed)", appName, filename, hex.EncodeToString(srcHash[:8]), hex.EncodeToString(dstHash[:8]))
}
// writeIfChanged writes data to dst only if the content differs. Returns true if written.
func writeIfChanged(data []byte, dst string) (bool, error) {
if cur, err := os.ReadFile(dst); err == nil && sha256.Sum256(cur) == sha256.Sum256(data) {
return false, nil
}
if err := os.WriteFile(dst, data, 0644); err != nil {
return false, fmt.Errorf("writing %s: %w", dst, err)
}
return true, nil
}
// copyIfChanged copies src to dst only if the content differs.
// Returns true if the file was actually written.
func copyIfChanged(src, dst string) (bool, error) {