v0.235.0: freeze the version, keep the fixes flowing (operator ruling 2026-09-06)
gates / gates (push) Successful in 12s

Slice 3. R-447 was BLOCKED because R-438 established that RestartStack's use of
up -d to pick up template changes was CHOSEN and written down in its own comment.
The operator ruled Option 1, and this implements it.

The rule: while the catalog offers the same version you run, its fixes flow to
you; the moment it moves to a newer version you are frozen until you update.

NOTHING was added to any of the thirteen compose up -d call sites. Most of them
are repairs - the boot reconciler, the drive-return gate, the app-stop guard -
and a repair path that refuses to repair leaves a customer's app down, which is
worse than the problem. They are made safe by removing the reason.

app.yaml gains pinned_images: what the app is SUPPOSED to run. It is NOT
installed_images, which is an observation; letting a reading become a deployment
is the R-166 category error one field over. Four writers, each also storing the
exact definition as applied-compose.yml. UpdateStack advances the pin and
re-renders BEFORE the pull, because pull and up -d act on the file on disk, and a
pin set afterwards would pull the frozen version and report success.

The syncer renders instead of copying, through one nil-safe seam. Catalog images
equal the pin -> verbatim, so fixes and self-healing both survive; they differ ->
the WHOLE stored definition, never a substitution of refs into a newer template
(wger 2.6 needs a DB config the older template cannot supply). This is
deliberately not 'skip deployed apps', which was option B and was rejected.

AdoptPins runs once at boot after the backfill, files only, and skips loudly
rather than inventing a pin. syncer.Start() moved to after it: the initial sync
would otherwise run while every app was unpinned and overwrite a deployed app's
version once per boot.

THE BADGE HAD TO CHANGE OR SLICE 2 WOULD HAVE INVERTED SILENTLY. TemplateImages
reads the LIVE compose file, which is now the frozen one, so the comparison would
have answered Naprakesz on exactly the apps that are behind - with every test
green, because the new field has the same type. It now reads CatalogImages.

+16 tests (1729 -> 1745), 28 packages green. Three red-proofs run and reverted.
A test also caught the syncer writing an empty compose file over a live app.
This commit is contained in:
2026-09-06 09:45:34 +02:00
parent 998aa31958
commit 8a0e0a59ad
13 changed files with 1437 additions and 17 deletions
+126
View File
@@ -17,6 +17,11 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
// stacks is imported for its PURE helpers only — RenderPlan and ParseComposeImages. The syncer
// must not reach for the Manager through it: everything it needs about an app arrives through
// renderPlanFn. Importing the package rather than re-writing a compose parser is deliberate;
// a second image parser is exactly the duplication REUSE.md exists to prevent.
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// gitCmdTimeout bounds each individual git subprocess (campaign finding F3, 2026-07-06):
@@ -33,6 +38,15 @@ type Syncer struct {
cacheDir string // local git clone
rescanFn func() error
postSyncHook func(updated []string) // called after sync with names of updated stacks
// renderPlanFn answers, for one app: is it deployed, is it pinned, and where is its stored
// applied definition (v0.235.0). It is the ONLY way this package learns any of that: the syncer
// must not import the stack manager and must NEVER read app.yaml itself — that file is the
// manager's and carries encrypted values.
//
// NIL-SAFE BY DESIGN: a nil seam means "copy verbatim", i.e. byte-for-byte the pre-v0.235.0
// behaviour. Every existing test that constructs a Syncer without one keeps passing, and a
// wiring mistake degrades to the old product rather than to a broken one.
renderPlanFn func(appName string) stacks.RenderPlan
mu sync.Mutex
lastSync time.Time
lastErr error
@@ -71,6 +85,11 @@ func New(cfg *config.Config, logger *log.Logger, rescanFn func() error, postSync
}
}
// SetRenderPlanFn injects the per-app render plan (v0.235.0). Exported and separate from New for the
// same reason SetSambaRunProbe is: New's signature is called from tests in several packages, and the
// seam has to be optional so a Syncer without one keeps the pre-v0.235.0 behaviour exactly.
func (s *Syncer) SetRenderPlanFn(fn func(appName string) stacks.RenderPlan) { s.renderPlanFn = fn }
// isDebug returns true if the logging level is set to "debug".
func (s *Syncer) isDebug() bool { return s.cfg.Logging.Level == "debug" }
@@ -356,6 +375,16 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
continue
}
// v0.235.0 — the RENDER. `.felhom.yml` is always copied verbatim (it holds no image);
// only the compose file can be frozen. See renderSource for the whole table.
if filename == "docker-compose.yml" {
frozenSrc, skip := s.renderSource(appName, src)
if skip {
continue
}
src = frozenSrc
}
changed, err := copyIfChanged(src, dst)
if err != nil {
s.logger.Printf("[WARN] [sync] Failed to copy catalog file %s/%s: %v", appName, filename, err)
@@ -382,6 +411,103 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
return newApps, updated, nil
}
// renderSource decides WHICH file becomes this app's live docker-compose.yml, and is the whole of
// the v0.235.0 ruling: "freeze the version, keep the fixes flowing" (operator, 2026-09-06).
//
// It returns the path to copy FROM, and whether to skip the app this cycle.
//
// ── THE TABLE, COMPLETE ──────────────────────────────────────────────────────────────────────
//
// not deployed / protected / no seam → the catalog template (today's behaviour)
// deployed, UNPINNED → the catalog template (today's behaviour) + one DEBUG
// deployed, pinned, catalog images == → the catalog template — FIXES FLOW, SELF-HEALING WORKS
// deployed, pinned, catalog images != → the STORED definition — the app is frozen WHOLE
// deployed, pinned, differ, none stored → the catalog template + one WARN
// mid-deploy → skip the compose file this cycle
//
// ── WHY THE FROZEN BRANCH WRITES A WHOLE FILE AND NEVER A SUBSTITUTION ───────────────────────
//
// The obvious-looking alternative — take the new template and put the old image refs back — creates
// a third state nobody chose: `wger 2.6` needs a full DB configuration the older template cannot
// supply, so a new template around an old image is broken in a way neither version is. When the
// catalog has moved, the WHOLE stored definition is used.
//
// ── AND WHY THIS IS NOT SIMPLY "SKIP DEPLOYED APPS" ──────────────────────────────────────────
//
// That was option B and it was rejected: it also stops health-check fixes, memory limits and new
// deploy fields reaching a deployed app, and it destroys the self-healing measured in
// SPIKE-app-update-2026-09-01 §3 — a hand-broken compose file repaired itself within 15 minutes.
// Both halves were worth keeping; only the version change was not.
func (s *Syncer) renderSource(appName, catalogSrc string) (src string, skip bool) {
if s.renderPlanFn == nil {
return catalogSrc, false // pre-v0.235.0 behaviour, byte for byte
}
plan := s.renderPlanFn(appName)
if !plan.Deployed || plan.Protected {
return catalogSrc, false
}
if plan.Deploying {
// Do not race an in-flight deploy for its own compose file.
if s.isDebug() {
s.logger.Printf("[DEBUG] [sync] %s: mid-deploy, compose file left alone this cycle", appName)
}
return "", true
}
if len(plan.Pinned) == 0 {
if s.isDebug() {
s.logger.Printf("[DEBUG] [sync] %s: deployed but UNPINNED — catalog copied verbatim (pre-v0.235.0 behaviour)", appName)
}
return catalogSrc, false
}
catalogImages, err := stacks.ParseComposeImages(catalogSrc)
if err != nil {
// CANNOT TELL whether the catalog has moved. Leave the app's file alone rather than guess in
// either direction — an unreadable catalog template must not be able to unfreeze an app.
s.logger.Printf("[WARN] [sync] %s: cannot read the catalog template's images (%v) — compose file left alone this cycle", appName, err)
return "", true
}
if samePin(plan.Pinned, catalogImages) {
// The catalog still offers what this app runs: everything else in the template is a FIX and
// is delivered, exactly as before v0.235.0. This branch is why the feature is not a freeze.
return catalogSrc, false
}
if plan.AppliedPath == "" {
// We cannot freeze what we do not have, and we must not invent it.
s.logger.Printf("[WARN] [sync] %s: the catalog has moved past this app's pinned version, but no stored definition exists — copying the catalog verbatim (pre-v0.235.0 behaviour). The app will take the new version on its next start.", appName)
return catalogSrc, false
}
// DEFENCE IN DEPTH, and this line was added because a test demanded it: the manager's
// RenderPlanFor already refuses to hand over a path whose file is missing or empty, but the
// syncer is what WRITES, and writing an empty compose file over a live app takes that app down.
// The cost of re-reading a small file once per app per cycle is nothing next to that.
if data, err := os.ReadFile(plan.AppliedPath); err != nil || len(strings.TrimSpace(string(data))) == 0 {
s.logger.Printf("[WARN] [sync] %s: the stored definition is missing or empty (%v) — copying the catalog verbatim rather than writing an empty compose file", appName, err)
return catalogSrc, false
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [sync] %s: catalog has moved past the pin — rendering the stored applied definition", appName)
}
return plan.AppliedPath, false
}
// samePin compares a pin against a template's images. Local to the syncer so this package needs
// nothing from the manager beyond the plan it is handed.
func samePin(pinned, catalog map[string]string) bool {
if len(pinned) != len(catalog) {
return false
}
for svc, ref := range pinned {
if got, ok := catalog[svc]; !ok || got != ref {
return false
}
}
return true
}
// logFileHashes logs the source and destination file hashes for debugging.
func (s *Syncer) logFileHashes(appName, filename, src, dst string) {
srcData, err := os.ReadFile(src)