Files
felhom-controller/controller/internal/stacks/pin.go
T
admin 2cd66663f3
gates / gates (push) Successful in 25s
v0.263.2: the undo keeps the probe of the pinned version (R-637)
Found live on 9202 (romm): .felhom.yml flows into the stack dir on every
catalog sync, so "the old .felhom.yml" saved at update time was already the
new one, and the serving old version was judged with the new probe.

New record applied-meta/.felhom.yml, written whenever a version is pinned
(deploy, adoption, pin advance) and put back by the undo, like
applied-compose.yml. The fixture now places the new file at sync time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 11:53:53 +02:00

368 lines
15 KiB
Go

package stacks
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// AppliedComposeFile is the stored copy of the docker-compose.yml an app was last brought up FROM.
//
// WHY IT LIVES IN THE STACK DIRECTORY, and why this exact name: `Syncer.copyTemplates` copies
// EXACTLY `docker-compose.yml` and `.felhom.yml` and nothing else, so any other name in that
// directory is safe from the catalog. Keeping it beside the app means it travels through every path
// that already moves a stack dir, and anyone debugging the box can see it without a tool.
//
// It is the FROZEN definition: when the catalog has moved past an app's pin, this whole file is
// written to docker-compose.yml. Never a substitution of pinned refs into a newer template — a
// template's env and volumes can belong to its version (`wger 2.6` needs a full DB config the older
// template cannot supply), and an old image under a new template is a third broken state nobody
// chose.
const AppliedComposeFile = "applied-compose.yml"
// AppliedComposePath is the single definition of where the stored definition lives.
func AppliedComposePath(stackDir string) string {
return filepath.Join(stackDir, AppliedComposeFile)
}
// StoreAppliedDefinition writes the compose definition this app is pinned to, atomically.
//
// Atomic tmp+rename for the same reason SaveAppConfig is: the syncer may read this file at any
// moment, and a half-written compose file rendered over a live app is a broken app.
func StoreAppliedDefinition(stackDir string, data []byte) error {
if len(data) == 0 {
// NEVER store an empty definition. An empty applied file would later be rendered over the
// live compose file and take the app down — see the render table's "unreadable or empty"
// row, which treats it as absent precisely so this cannot happen.
return fmt.Errorf("refusing to store an empty applied definition for %s", filepath.Base(stackDir))
}
path := AppliedComposePath(stackDir)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("renaming %s to %s: %w", tmp, path, err)
}
return nil
}
// LoadAppliedDefinition returns the stored definition, or an error when there is none or it is
// unusable. An empty file is an ERROR, not empty content — see the render table.
func LoadAppliedDefinition(stackDir string) ([]byte, error) {
data, err := os.ReadFile(AppliedComposePath(stackDir))
if err != nil {
return nil, err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil, fmt.Errorf("stored applied definition for %s is empty", filepath.Base(stackDir))
}
return data, nil
}
// SetPin records what an app is SUPPOSED to run and stores the definition that pin came from.
//
// PIN FIRST, THEN STORE, and the degradation is deliberate: if the pin lands and the store fails,
// the app is pinned with no stored definition, and the render table's own row for that case copies
// the catalog verbatim and WARNs. That is today's behaviour plus a loud line — the same outcome as
// being unpinned, never a silent freeze onto a definition we do not have.
//
// `composeSrc` MUST be the exact bytes the pin was derived from. Passing a different file is how a
// stack ends up frozen onto something it never ran.
func (m *Manager) SetPin(name, stackDir string, pin map[string]string, composeSrc []byte) error {
if len(pin) == 0 {
return fmt.Errorf("refusing to pin %s to an empty image set", name)
}
cfg := LoadAppConfig(stackDir)
if cfg == nil {
// No app.yaml means nothing is deployed here. Not an error — the same no-op
// SetDesiredState makes for the same reason.
m.logger.Printf("[DEBUG] [stacks] pin %s: no app.yaml — nothing deployed here, nothing to pin", name)
return nil
}
if !samePin(cfg.PinnedImages, pin) {
cfg.PinnedImages = pin
meta := LoadMetadata(stackDir)
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("recording pin for %s: %w", name, err)
}
m.logger.Printf("[INFO] [stacks] pin %s: %s", name, summarisePin(pin))
m.mu.Lock()
if st, ok := m.stacks[name]; ok && st.AppConfig != nil {
st.AppConfig.PinnedImages = pin
}
m.mu.Unlock()
}
if err := StoreAppliedDefinition(stackDir, composeSrc); err != nil {
// Loud, and NOT fatal to the pin — see the comment above.
m.logger.Printf("[ERROR] [stacks] pin %s: the pin is recorded but its definition could not be stored (the app is unaffected; the catalog will be copied verbatim until this is fixed): %v", name, err)
}
return nil
}
// samePin compares two pins by content so an unchanged pin does not rewrite app.yaml — that file
// holds encrypted secrets and rewriting it for no new information is pure risk (the SetDesiredState
// rule).
func samePin(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, va := range a {
if vb, ok := b[k]; !ok || va != vb {
return false
}
}
return true
}
// summarisePin renders a pin for ONE log line. Image refs only — this file never logs anything out
// of app.yaml's env map, which holds encrypted secrets.
func summarisePin(pin map[string]string) string {
svcs := make([]string, 0, len(pin))
for svc := range pin {
svcs = append(svcs, svc)
}
sort.Strings(svcs)
parts := make([]string, 0, len(svcs))
for _, svc := range svcs {
parts = append(parts, svc+"="+pin[svc])
}
return strings.Join(parts, ", ")
}
// ComposePathIn resolves a stack directory's compose file, honouring the .yml/.yaml pair exactly as
// ScanStacks does. One rule, so a caller cannot pin from a file the scanner would not have read.
func ComposePathIn(stackDir string) string {
p := filepath.Join(stackDir, "docker-compose.yml")
if _, err := os.Stat(p); err == nil {
return p
}
alt := filepath.Join(stackDir, "docker-compose.yaml")
if _, err := os.Stat(alt); err == nil {
return alt
}
return p // the canonical name; the caller's read will report the real error
}
// PinFromCompose reads a compose file and returns both the pin it implies and its exact bytes, so a
// caller cannot accidentally pin from one file and store another.
func PinFromCompose(composePath string) (map[string]string, []byte, error) {
images, err := ParseComposeImages(composePath)
if err != nil {
return nil, nil, err
}
if len(images) == 0 {
return nil, nil, fmt.Errorf("%s declares no images", composePath)
}
data, err := os.ReadFile(composePath)
if err != nil {
return nil, nil, err
}
return images, data, nil
}
// --- what the syncer is told ---
// RenderPlan is the per-app answer the stack manager gives the catalog syncer.
//
// It carries FACTS, not a decision: the render table lives in the syncer, which is the thing doing
// the writing. The syncer must never read app.yaml itself — that file is the manager's and carries
// encrypted values — so everything it needs to apply the table comes through here.
type RenderPlan struct {
Deployed bool
Deploying bool
Protected bool
Pinned map[string]string // service -> image ref; nil/empty means UNPINNED
AppliedPath string // the stored definition; "" when none is stored
// StackDir lets the syncer REFRESH the stored definition when it copies the catalog verbatim
// into a pinned app whose images still match. See Syncer.renderSource — without that refresh the
// stored definition goes stale relative to the fixes that flowed after it, and the freeze would
// later undo them. Found by the live validation of v0.235.0, not by review.
StackDir string
}
// RenderPlanFor answers for one app by name. Unknown apps come back as an empty plan, which the
// syncer reads as "not deployed" — i.e. copy verbatim, exactly the pre-v0.235.0 behaviour.
func (m *Manager) RenderPlanFor(name string) RenderPlan {
s, ok := m.GetStack(name)
if !ok {
return RenderPlan{}
}
plan := RenderPlan{
Deployed: s.Deployed,
Deploying: s.Deploying,
Protected: s.Protected,
}
if s.AppConfig != nil && len(s.AppConfig.PinnedImages) > 0 {
plan.Pinned = s.AppConfig.PinnedImages
}
stackDir := filepath.Dir(s.ComposePath)
plan.StackDir = stackDir
if _, err := LoadAppliedDefinition(stackDir); err == nil {
plan.AppliedPath = AppliedComposePath(stackDir)
}
return plan
}
// --- adoption ---
// AdoptPins gives a pin to every deployed app that has none, and stores the definition it is
// running. Call ONCE at startup, immediately AFTER BackfillInstalledImages so an app the backfill
// has just observed can be pinned in the same boot.
//
// ── IT NEVER GUESSES, AND THAT IS MOST OF THE CODE ───────────────────────────────────────────
//
// Two skips, each with a reason that is not interchangeable:
//
// 1. The observation is INCOMPLETE (stopped, crash-looping, mid-anything). We do not know what the
// app runs, so we cannot say what it should run. Reuses observationCoversTemplate — the SAME
// completeness rule the backfill uses, deliberately not a second one.
// 2. The observation is complete but DIFFERS from the current template. The app is already running
// something the catalog no longer offers, and we have no stored definition for it. Pinning here
// would be right, but the FREEZE would then render a definition we do not possess — and the only
// way to manufacture one is to substitute the running refs into the newer template, which is
// exactly the third-broken-state this design refuses (see AppliedComposeFile).
//
// In both cases the app keeps behaving exactly as it did before v0.235.0, loudly. Absent means
// unknown; unknown is never resolved by inventing an answer.
//
// It reads and writes FILES only. It starts, stops and touches no container.
func (m *Manager) AdoptPins() int {
pinned, alreadyPinned, skippedIncomplete, skippedMismatch := 0, 0, 0, 0
for _, s := range m.GetStacks() {
if !s.Deployed || s.Protected || s.Deploying {
continue
}
if s.AppConfig != nil && len(s.AppConfig.PinnedImages) > 0 {
alreadyPinned++
continue
}
stackDir := filepath.Dir(s.ComposePath)
tpl, err := ParseComposeImages(s.ComposePath)
if err != nil || len(tpl) == 0 {
m.logger.Printf("[WARN] [stacks] pin adoption: %s left UNPINNED — its compose file declares no readable images (%v). It keeps pre-v0.235.0 behaviour.", s.Name, err)
skippedIncomplete++
continue
}
var observed map[string]InstalledImage
if s.AppConfig != nil {
observed = s.AppConfig.InstalledImages
}
if !observationCoversTemplate(observed, tpl) {
m.logger.Printf("[WARN] [stacks] pin adoption: %s left UNPINNED — no complete record of what it is running (%d of %d service(s) observed). It keeps pre-v0.235.0 behaviour.",
s.Name, len(observed), len(tpl))
skippedIncomplete++
continue
}
if diff := pinMismatch(observed, tpl); diff != "" {
m.logger.Printf("[WARN] [stacks] pin adoption: %s left UNPINNED — it is running something the current template no longer offers, and there is no stored definition for it: %s. It keeps pre-v0.235.0 behaviour.",
s.Name, diff)
skippedMismatch++
continue
}
_, data, err := PinFromCompose(s.ComposePath)
if err != nil {
m.logger.Printf("[WARN] [stacks] pin adoption: %s: %v", s.Name, err)
skippedIncomplete++
continue
}
if err := m.SetPin(s.Name, stackDir, tpl, data); err != nil {
m.logger.Printf("[ERROR] [stacks] pin adoption: %s: %v", s.Name, err)
continue
}
m.storeAppliedMetaFrom(s.Name, stackDir, filepath.Join(stackDir, ".felhom.yml")) // adopted only when running == template
pinned++
}
// A POSITIVE OBSERVABLE EITHER WAY (standing rule 3): "0 pinned" and "the pass never ran" must
// not look the same in a log.
m.logger.Printf("[INFO] [stacks] pin adoption: %d pinned, %d already pinned, %d left unpinned (%d not completely observed, %d running something the template no longer offers)",
pinned, alreadyPinned, skippedIncomplete+skippedMismatch, skippedIncomplete, skippedMismatch)
return pinned
}
// pinMismatch returns a human description of the first service whose RUNNING reference differs from
// what the template pins, or "" when they agree everywhere. Deterministic order so two runs produce
// the same line.
func pinMismatch(observed map[string]InstalledImage, tpl map[string]string) string {
svcs := make([]string, 0, len(tpl))
for svc := range tpl {
svcs = append(svcs, svc)
}
sort.Strings(svcs)
var diffs []string
for _, svc := range svcs {
got, ok := observed[svc]
if !ok {
continue // completeness was already checked
}
if got.Ref != tpl[svc] {
diffs = append(diffs, fmt.Sprintf("%s runs %s, template offers %s", svc, got.Ref, tpl[svc]))
}
}
return strings.Join(diffs, "; ")
}
// CatalogTemplatePath is where the syncer's git clone keeps one app's template. It is the ONLY
// definition of that layout outside the syncer, and the badge and the update path both use it.
func (m *Manager) CatalogTemplatePath(appName, filename string) string {
return filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates", appName, filename)
}
// advancePinToCatalog moves a PINNED app onto the catalog's current definition: it writes the
// catalog template over the live compose file, records the new pin, and stores that definition as
// the applied one. Called by UpdateStack BEFORE the pull — see the comment at that call site.
//
// AN UNPINNED APP IS LEFT ALONE AND THIS RETURNS NIL. It behaves exactly as it did before v0.235.0:
// the syncer has already copied the catalog verbatim into its stack dir, so `pull` + `up -d` do
// today's job with no help from here.
func (m *Manager) advancePinToCatalog(name, stackDir string) error {
cfg := LoadAppConfig(stackDir)
if cfg == nil || len(cfg.PinnedImages) == 0 {
return nil // unpinned — today's behaviour, unchanged
}
src := m.CatalogTemplatePath(name, "docker-compose.yml")
pin, data, err := PinFromCompose(src)
if err != nil {
// REFUSE rather than silently update to the frozen definition (which would be a no-op
// reported as success). Names the cause so the operator is not left guessing.
return fmt.Errorf("cannot read the catalog's current definition for %s (%s): %w", name, src, err)
}
live := ComposePathIn(stackDir)
if err := StoreAppliedDefinition(stackDir, data); err != nil {
return fmt.Errorf("storing the new applied definition for %s: %w", name, err)
}
if err := os.WriteFile(live, data, 0o644); err != nil {
return fmt.Errorf("rendering the catalog definition for %s: %w", name, err)
}
cfg.PinnedImages = pin
meta := LoadMetadata(stackDir)
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("recording the advanced pin for %s: %w", name, err)
}
m.mu.Lock()
if st, ok := m.stacks[name]; ok && st.AppConfig != nil {
st.AppConfig.PinnedImages = pin
}
m.mu.Unlock()
// v0.263.2: the new version's own .felhom.yml becomes the pinned version's record.
m.storeAppliedMetaFrom(name, stackDir, m.CatalogTemplatePath(name, ".felhom.yml"))
m.logger.Printf("[INFO] [stacks] update %s: pin advanced to the catalog's current definition (%s)", name, summarisePin(pin))
return nil
}