controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s
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:
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -601,7 +602,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
// R-489 (v0.242.0): the volumes are listed BEFORE and AFTER; the difference is what was removed.
|
||||
// Parsing compose's progress output reported `null` over volumes it did remove — measured five
|
||||
// times on demo-hp 2026-09-13 — because compose prints that progress to a TTY it does not have here.
|
||||
volsBefore := m.projectVolumes(name)
|
||||
volsBefore := m.appVolumeSet(name, stackDir)
|
||||
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--volumes")
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: compose down output: %s", name, truncateStr(output, 500))
|
||||
@@ -629,7 +630,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
}
|
||||
|
||||
// Step 3: the volumes that are gone now — `[]` when none, never null (R-489).
|
||||
resp.VolumesRemoved = removedVolumes(volsBefore, m.projectVolumes(name))
|
||||
resp.VolumesRemoved = removedVolumes(volsBefore, m.appVolumeSet(name, stackDir))
|
||||
if len(resp.VolumesRemoved) > 0 {
|
||||
m.logger.Printf("[INFO] [stacks] RemoveStack %s: removed volume(s) %v", name, resp.VolumesRemoved)
|
||||
}
|
||||
@@ -719,6 +720,16 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
m.logger.Printf("[ERROR] Failed to remove %s: %v", appYAMLPath, err)
|
||||
return resp, fmt.Errorf("failed to remove app.yaml: %w", err)
|
||||
}
|
||||
// R-651 (v0.268.0): the pinned definition and the pinned version's .felhom.yml go with the app.
|
||||
// MEASURED 2026-09-23 night on 9202: every remove left `applied-compose.yml` and `applied-meta/`
|
||||
// behind, so a reinstall of the same name started beside the removed install's record — which the
|
||||
// undo reads as "the version to go back to". The rest of the directory is the catalog mirror and
|
||||
// stays. Pinned by TestR651_RemoveDeletesTheAppliedRecord.
|
||||
for _, p := range []string{AppliedComposePath(stackDir), filepath.Join(stackDir, appliedMetaDir)} {
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] RemoveStack %s: could not remove %s: %v", name, p, err)
|
||||
}
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s removed successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
|
||||
@@ -1004,6 +1015,37 @@ func (m *Manager) projectVolumes(project string) []string {
|
||||
return vols
|
||||
}
|
||||
|
||||
// appVolumeSet is every volume the removal accounts for: the ones carrying the project label AND the
|
||||
// ones the app's definition declares that Docker holds by name (R-658, v0.268.0). A restore before
|
||||
// v0.268.0 recreated volumes without the label, and the remove then answered `volumes_removed: []`
|
||||
// over volumes compose's `down --volumes` did remove (measured at the 2026-09-23 night's teardown).
|
||||
// A listing failure reads as nothing, so a removal never fails on bookkeeping.
|
||||
func (m *Manager) appVolumeSet(project, stackDir string) []string {
|
||||
set := map[string]bool{}
|
||||
for _, v := range m.projectVolumes(project) {
|
||||
set[v] = true
|
||||
}
|
||||
if declared, _, err := DeclaredVolumeNames(ComposePathIn(stackDir)); err == nil && len(declared) > 0 {
|
||||
if out, lerr := m.execCommand("docker", "volume", "ls", "-q"); lerr == nil {
|
||||
have := map[string]bool{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
have[strings.TrimSpace(l)] = true
|
||||
}
|
||||
for _, d := range declared {
|
||||
if have[d] {
|
||||
set[d] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for v := range set {
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// removedVolumes is before minus after, as a non-nil slice (the JSON must read `[]`, not `null`).
|
||||
func removedVolumes(before, after []string) []string {
|
||||
still := map[string]bool{}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ── The update ladder on the box (`09` §3 decision 14, §6.4 part 5; controller v0.268.0) ─────────
|
||||
//
|
||||
// WHAT IT REPLACES. Until v0.267.0 one press moved an app straight to the catalog's CURRENT
|
||||
// definition, however many tested steps lay between. Measured 2026-09-23: vikunja installed at 2.3.0,
|
||||
// the drill catalog then took 2.4.0 and 2.5.0, one press → 2.5.0 in 9.5 s and 2.4.0 never ran; and on
|
||||
// demo-hp's 9201 romm took its app step AND its engine step in one press — each tested alone, never
|
||||
// together. Decision 14: a box behind climbs ONE TESTED STEP at a time, in order, and never jumps.
|
||||
//
|
||||
// WHERE A STEP'S DEFINITION LIVES. Not in git history — the box's catalog clone is `--depth 1`
|
||||
// (`sync.go`), and the commit that moved an image is not always the definition that works (romm's
|
||||
// 15f9ebf OOM-looped; the working step is its images under the later f4eb94f template). So the catalog
|
||||
// keeps, for every step but the newest, the step's OWN complete compose file at
|
||||
// `templates/<app>/steps/<StepKey(to)>.yml`, written by `upgrade-test.py --write-ladder` and refused
|
||||
// absent by `check-test-record.py`. The newest step's definition is the template's docker-compose.yml.
|
||||
// The box reads both straight from its clone; the syncer copies nothing new into the stack dir.
|
||||
//
|
||||
// ONE PRESS = ONE STEP. The guarded update (§6.1) runs unchanged around it: same precondition, same
|
||||
// safety dump, same undo copy, same health wait, same undo. Only the definition it pins differs.
|
||||
//
|
||||
// AN APP OLDER THAN THE LADDER. An installed version that matches no entry's `from` has no record to
|
||||
// climb — today's behaviour (the catalog's current definition) applies, logged by name. Pinned by
|
||||
// TestLadder_UnknownInstalledJumpsAndSaysSo.
|
||||
|
||||
// LadderEntry is one line of `.felhom.yml`'s `update_ladder:` — the fields the box reads. The catalog's
|
||||
// `scripts/ladder.py` documents the whole record.
|
||||
type LadderEntry struct {
|
||||
From map[string]string `yaml:"from" json:"from"`
|
||||
To map[string]string `yaml:"to" json:"to"`
|
||||
Digest map[string]string `yaml:"digest" json:"digest"`
|
||||
Verdict string `yaml:"verdict" json:"verdict"`
|
||||
}
|
||||
|
||||
type ladderDoc struct {
|
||||
UpdateLadder []LadderEntry `yaml:"update_ladder"`
|
||||
}
|
||||
|
||||
// LoadLadder reads the ladder from a `.felhom.yml`. No key → (nil, nil). The entries are JSON flow
|
||||
// mappings, which YAML reads as ordinary maps.
|
||||
func LoadLadder(felhomPath string) ([]LadderEntry, error) {
|
||||
data, err := os.ReadFile(felhomPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var d ladderDoc
|
||||
if err := yaml.Unmarshal(data, &d); err != nil {
|
||||
return nil, fmt.Errorf("parsing update_ladder in %s: %w", felhomPath, err)
|
||||
}
|
||||
return d.UpdateLadder, nil
|
||||
}
|
||||
|
||||
// StepKey names a step's definition file: the first 16 hex of the sha256 of `to` as canonical JSON —
|
||||
// keys sorted, no spaces, no HTML escaping. The catalog computes the SAME string in Python
|
||||
// (`json.dumps(to, sort_keys=True, separators=(",", ":"))`); TestLadder_StepKeyMatchesTheCatalog pins
|
||||
// one real value both sides print.
|
||||
func StepKey(to map[string]string) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(to) // a map[string]string always encodes; Go sorts map keys
|
||||
sum := sha256.Sum256(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// StepFile is the step's definition path inside a template directory.
|
||||
func StepFile(templateDir string, to map[string]string) string {
|
||||
return filepath.Join(templateDir, "steps", StepKey(to)+".yml")
|
||||
}
|
||||
|
||||
func sameRefs(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if b[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// LadderStep is the one step the next press applies.
|
||||
type LadderStep struct {
|
||||
// Index of the entry in the ladder, -1 when the installed version matches no entry.
|
||||
Index int
|
||||
// Left counts the steps from this one to the head, this one included. 0 when unknown.
|
||||
Left int
|
||||
// Source is the compose file the step pins: a steps/ file, or the template's docker-compose.yml.
|
||||
Source string
|
||||
// Why is one operator-English sentence for the log.
|
||||
Why string
|
||||
}
|
||||
|
||||
// nextLadderStep decides WHICH definition the next press pins, from the catalog template directory
|
||||
// and the app's current pin. It never guesses: a missing or wrong step file is an error, and the
|
||||
// update refuses before anything moves (a jump past a tested step is the thing this exists to stop).
|
||||
func nextLadderStep(templateDir string, pinned map[string]string) (LadderStep, error) {
|
||||
current := filepath.Join(templateDir, "docker-compose.yml")
|
||||
ladder, err := LoadLadder(filepath.Join(templateDir, ".felhom.yml"))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return LadderStep{}, err
|
||||
}
|
||||
if len(ladder) == 0 {
|
||||
return LadderStep{Index: -1, Source: current, Why: "the template carries no update_ladder — the catalog's current definition"}, nil
|
||||
}
|
||||
idx := -1
|
||||
for i := len(ladder) - 1; i >= 0; i-- { // the NEWEST entry whose `from` is what runs
|
||||
if sameRefs(ladder[i].From, pinned) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return LadderStep{Index: -1, Source: current,
|
||||
Why: fmt.Sprintf("the installed version %s matches no update_ladder entry (%d entries) — an app older than the ladder has no record to climb; the catalog's current definition", summarisePin(pinned), len(ladder))}, nil
|
||||
}
|
||||
left := len(ladder) - idx
|
||||
if idx == len(ladder)-1 {
|
||||
return LadderStep{Index: idx, Left: left, Source: current,
|
||||
Why: fmt.Sprintf("the last step (%d of %d) — the catalog's current definition", idx+1, len(ladder))}, nil
|
||||
}
|
||||
src := StepFile(templateDir, ladder[idx].To)
|
||||
imgs, perr := ParseComposeImages(src)
|
||||
if perr != nil {
|
||||
return LadderStep{}, fmt.Errorf("step %d of %d (%s) has no definition at %s: %w", idx+1, len(ladder), summarisePin(ladder[idx].To), src, perr)
|
||||
}
|
||||
if !sameRefs(imgs, ladder[idx].To) {
|
||||
return LadderStep{}, fmt.Errorf("step %d of %d: %s names %s, the ladder says %s", idx+1, len(ladder), src, summarisePin(imgs), summarisePin(ladder[idx].To))
|
||||
}
|
||||
return LadderStep{Index: idx, Left: left, Source: src,
|
||||
Why: fmt.Sprintf("step %d of %d: %s → %s, from %s", idx+1, len(ladder), summarisePin(ladder[idx].From), summarisePin(ladder[idx].To), filepath.Base(src))}, nil
|
||||
}
|
||||
|
||||
// ladderStepsLeft is the page's count: how many tested steps separate this pin from the catalog's
|
||||
// head. 0 = unknown or none (no ladder, no match, or already at the head).
|
||||
func ladderStepsLeft(templateDir string, pinned map[string]string) int {
|
||||
if len(pinned) == 0 {
|
||||
return 0
|
||||
}
|
||||
ladder, err := LoadLadder(filepath.Join(templateDir, ".felhom.yml"))
|
||||
if err != nil || len(ladder) == 0 {
|
||||
return 0
|
||||
}
|
||||
for i := len(ladder) - 1; i >= 0; i-- {
|
||||
if sameRefs(ladder[i].From, pinned) {
|
||||
return len(ladder) - i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// v0.268.0 — the update ladder on the box (`09` §3 decision 14, §6.4 part 5). Every test runs the REAL
|
||||
// guarded update job with the process boundaries faked, and reads the EFFECT back: the pin in
|
||||
// app.yaml, the live compose file's bytes, and every definition `up` was ever run on.
|
||||
|
||||
const (
|
||||
ladderA = "nextcloud:31.0.14-apache"
|
||||
ladderB = "nextcloud:33.0.0-apache"
|
||||
ladderC = "nextcloud:34.0.1-apache" // = pinTplNew, the catalog's current definition
|
||||
// STEP B's OWN definition carries a line the template does not — the thing that proves the press
|
||||
// rendered the step's definition and not the new template with B's image substituted in.
|
||||
ladderBDef = "services:\n web:\n image: " + ladderB + "\n environment:\n - STEP_B_OWN_DEFINITION=1\nvolumes:\n db:\n"
|
||||
)
|
||||
|
||||
func ladderLine(from, to string) string {
|
||||
return ` - {"from": {"web": "` + from + `"}, "to": {"web": "` + to + `"}, "digest": {"web": "sha256:` +
|
||||
strings.Repeat("a", 64) + `"}, "verdict": "proven"}` + "\n"
|
||||
}
|
||||
|
||||
// ladderManager: slice 4's manager, pinned at A, the catalog at C with a two-step ladder A→B→C and
|
||||
// B's own definition in steps/. `ups` records the image of the live compose file at every `up`.
|
||||
func ladderManager(t *testing.T, withStepFile bool) (*Manager, string, *fakeGuards, *[]string, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
m, dir, g, c := newSlice4Manager(t)
|
||||
logBuf := &bytes.Buffer{}
|
||||
var logMu sync.Mutex
|
||||
m.logger = log.New(writerFunc(func(p []byte) (int, error) { logMu.Lock(); defer logMu.Unlock(); return logBuf.Write(p) }), "", 0)
|
||||
catDir := filepath.Dir(m.CatalogTemplatePath("nextcloud", "docker-compose.yml"))
|
||||
mustWrite(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Nextcloud\nupdate_ladder:\n"+ladderLine(ladderA, ladderB)+ladderLine(ladderB, ladderC))
|
||||
if withStepFile {
|
||||
mustWriteMk(t, StepFile(catDir, map[string]string{"web": ladderB}), ladderBDef)
|
||||
}
|
||||
ups := &[]string{}
|
||||
var mu sync.Mutex
|
||||
m.updateComposeFn = func(d string, env []string, args ...string) (string, error) {
|
||||
if args[0] == "up" {
|
||||
imgs, _ := ParseComposeImages(ComposePathIn(d))
|
||||
mu.Lock()
|
||||
*ups = append(*ups, imgs["web"])
|
||||
mu.Unlock()
|
||||
}
|
||||
return c.fn(d, env, args...)
|
||||
}
|
||||
return m, dir, g, ups, logBuf
|
||||
}
|
||||
|
||||
type writerFunc func([]byte) (int, error)
|
||||
|
||||
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
|
||||
|
||||
// TestLadder_TwoPressesTwoSteps — A→B→C in TWO presses: the first renders B's OWN definition, the
|
||||
// second the catalog's current one. The badge's count follows.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make nextLadderStep always return the template (v0.267.0's jump).
|
||||
// This test then fails at "press 1 pinned nextcloud:34.0.1-apache, want the tested step B".
|
||||
func TestLadder_TwoPressesTwoSteps(t *testing.T) {
|
||||
m, dir, _, ups, _ := ladderManager(t, true)
|
||||
catDir := filepath.Dir(m.CatalogTemplatePath("nextcloud", "docker-compose.yml"))
|
||||
if n := ladderStepsLeft(catDir, map[string]string{"web": ladderA}); n != 2 {
|
||||
t.Fatalf("steps left from A = %d, want 2", n)
|
||||
}
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := waitUpdateDone(t, m, "nextcloud")
|
||||
if st.UpdatePhase != UpdatePhaseDone {
|
||||
t.Fatalf("press 1 ended %q (%s)", st.UpdatePhase, st.UpdateError)
|
||||
}
|
||||
if got := pinOf(t, dir); got != ladderB {
|
||||
t.Fatalf("press 1 pinned %s, want the tested step B %s — one press must be one step", got, ladderB)
|
||||
}
|
||||
if body := fileBody(t, ComposePathIn(dir)); body != ladderBDef {
|
||||
t.Fatalf("press 1 rendered\n%s\nwant step B's OWN definition (with STEP_B_OWN_DEFINITION)", body)
|
||||
}
|
||||
if body := fileBody(t, AppliedComposePath(dir)); body != ladderBDef {
|
||||
t.Fatal("the stored applied definition is not step B's — the sync would render the wrong file while the app sits at B")
|
||||
}
|
||||
if n := ladderStepsLeft(catDir, map[string]string{"web": ladderB}); n != 1 {
|
||||
t.Fatalf("steps left from B = %d, want 1", n)
|
||||
}
|
||||
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st = waitUpdateDone(t, m, "nextcloud")
|
||||
if st.UpdatePhase != UpdatePhaseDone || pinOf(t, dir) != ladderC {
|
||||
t.Fatalf("press 2 ended %q on %s, want done on C", st.UpdatePhase, pinOf(t, dir))
|
||||
}
|
||||
if body := fileBody(t, ComposePathIn(dir)); body != pinTplNew {
|
||||
t.Fatalf("press 2 must render the catalog's current definition; got\n%s", body)
|
||||
}
|
||||
if strings.Join(*ups, ",") != ladderB+","+ladderC {
|
||||
t.Fatalf("up ran on %v, want exactly [B, C] — never C first", *ups)
|
||||
}
|
||||
if n := ladderStepsLeft(catDir, map[string]string{"web": ladderC}); n != 0 {
|
||||
t.Fatalf("steps left at the head = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLadder_UnknownInstalledJumpsAndSaysSo — an installed version older than the ladder has no record
|
||||
// to climb: today's behaviour (the catalog's current definition), named in the log.
|
||||
func TestLadder_UnknownInstalledJumpsAndSaysSo(t *testing.T) {
|
||||
m, dir, _, _, logBuf := ladderManager(t, true)
|
||||
old := "services:\n web:\n image: nextcloud:30.0.0-apache\nvolumes:\n db:\n"
|
||||
mustWrite(t, ComposePathIn(dir), old)
|
||||
mustWrite(t, AppliedComposePath(dir), old)
|
||||
mustWrite(t, filepath.Join(dir, "app.yaml"), "deployed: true\nenv: {}\npinned_images:\n web: nextcloud:30.0.0-apache\n")
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseDone || pinOf(t, dir) != ladderC {
|
||||
t.Fatalf("ended %q on %s, want done on the catalog's current C", st.UpdatePhase, pinOf(t, dir))
|
||||
}
|
||||
if !strings.Contains(logBuf.String(), "matches no update_ladder entry") || !strings.Contains(logBuf.String(), "nextcloud:30.0.0-apache") {
|
||||
t.Fatalf("the jump must be logged by name; log:\n%s", logBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLadder_FailedStepStopsTheLadder — B fails its health check: the undo puts A back, and C is never
|
||||
// attempted (no definition naming C is ever brought up).
|
||||
func TestLadder_FailedStepStopsTheLadder(t *testing.T) {
|
||||
m, dir, _, ups, _ := ladderManager(t, true)
|
||||
fc := newFakeCopier(map[string]string{undoVol: "OLD"})
|
||||
m.undoCopier = fc
|
||||
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)
|
||||
}
|
||||
st := waitUpdateDone(t, m, "nextcloud")
|
||||
if st.UpdatePhase != UpdatePhaseUndone || pinOf(t, dir) != ladderA {
|
||||
t.Fatalf("ended %q on %s, want undone back on A", st.UpdatePhase, pinOf(t, dir))
|
||||
}
|
||||
for _, u := range *ups {
|
||||
if u == ladderC {
|
||||
t.Fatalf("C was brought up after B failed: ups=%v", *ups)
|
||||
}
|
||||
}
|
||||
if cfg := LoadAppConfig(dir); cfg == nil || cfg.LastUpdateUndone == nil || cfg.LastUpdateUndone.To["web"] != ladderB {
|
||||
t.Fatal("last_update_undone must name step B — the record the automatic caller will read to stop the ladder")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLadder_MissingStepFileRefusesBeforeAnythingMoves — the catalog promises step B and does not
|
||||
// carry its definition: the press refuses; it never jumps past B.
|
||||
func TestLadder_MissingStepFileRefusesBeforeAnythingMoves(t *testing.T) {
|
||||
m, dir, g, ups, _ := ladderManager(t, false)
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := waitUpdateDone(t, m, "nextcloud")
|
||||
if st.UpdatePhase != UpdatePhaseFailed || st.UpdateErrorKey != "update.error.pin_failed" {
|
||||
t.Fatalf("phase=%q key=%q, want failed/pin_failed", st.UpdatePhase, st.UpdateErrorKey)
|
||||
}
|
||||
if pinOf(t, dir) != ladderA || len(*ups) != 0 {
|
||||
t.Fatalf("pin=%s ups=%v — nothing may move", pinOf(t, dir), *ups)
|
||||
}
|
||||
for _, c := range g.callList() {
|
||||
if c == "SafetyDump" || c == "BackupNow" || c == "RestorePoints" {
|
||||
t.Fatalf("the refusal must come before the precondition, the backup and the safety dump; calls=%v", g.callList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The step key is the catalog's: one value printed by Python's
|
||||
// hashlib.sha256(json.dumps(to, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16].
|
||||
func TestLadder_StepKeyMatchesTheCatalog(t *testing.T) {
|
||||
if got := StepKey(map[string]string{"romm": "rommapp/romm:5.3.1", "romm-db": "mariadb:11.4", "romm-redis": "redis:7-alpine"}); got != "90dd9d68258286ef" {
|
||||
t.Fatalf("StepKey = %s, the catalog computes 90dd9d68258286ef", got)
|
||||
}
|
||||
if got := StepKey(map[string]string{"web": ladderB}); got != "e5a8dc3d17e505bc" {
|
||||
t.Fatalf("StepKey = %s, the catalog computes e5a8dc3d17e505bc", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A step file whose images disagree with its ladder entry is refused, never rendered.
|
||||
func TestLadder_StepFileMustNameTheStepsImages(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
mustWrite(t, filepath.Join(d, ".felhom.yml"), "update_ladder:\n"+ladderLine(ladderA, ladderB)+ladderLine(ladderB, ladderC))
|
||||
mustWriteMk(t, StepFile(d, map[string]string{"web": ladderB}), "services:\n web:\n image: "+ladderC+"\n")
|
||||
if _, err := nextLadderStep(d, map[string]string{"web": ladderA}); err == nil {
|
||||
t.Fatal("a step file naming C under B's key was accepted")
|
||||
}
|
||||
if err := os.Remove(StepFile(d, map[string]string{"web": ladderB})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := nextLadderStep(d, map[string]string{"web": ladderA}); err == nil {
|
||||
t.Fatal("a missing step file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteMk(t *testing.T, p, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, p, body)
|
||||
}
|
||||
@@ -167,9 +167,12 @@ type Stack struct {
|
||||
// HoldReason is the customer sentence of a hold in force on this app (a failed update or a failed
|
||||
// restore), "" when none. Filled on every read from the ONE hold store, never cached, so the page
|
||||
// and the API cannot show a hold the gate has already lifted — or miss one it enforces.
|
||||
HoldReason string `json:"hold_reason,omitempty"`
|
||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
HoldReason string `json:"hold_reason,omitempty"`
|
||||
// HoldNoWholeCopy (v0.268.0, R-659): the hold names NO copy — none on this box brings the app back
|
||||
// whole — so the page offers no restore button beside the sentence (the restore would refuse).
|
||||
HoldNoWholeCopy bool `json:"hold_no_whole_copy,omitempty"`
|
||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
// RestartingSince (C9-F2) is when this stack was FIRST observed in StateRestarting during the
|
||||
// current restarting run; zero whenever the stack is in any other state. It is what turns a brief
|
||||
// restart (normal: deploy, update, quiesce restart) into a distinguishable crash loop — see
|
||||
@@ -197,6 +200,11 @@ type Stack struct {
|
||||
// Nil means CANNOT-TELL — the cache is missing, unreadable, or the app is not in the catalog —
|
||||
// and the badge then renders NOTHING. Absent is unknown; it is never „Naprakész".
|
||||
CatalogImages map[string]string `json:"catalog_images,omitempty"`
|
||||
|
||||
// LadderStepsLeft (v0.268.0, `09` §3 decision 14) — how many tested steps lie between the app's
|
||||
// pin and the catalog's head; one press climbs one. 0 = unknown or none. The page shows it while
|
||||
// the badge says „Frissítés elérhető".
|
||||
LadderStepsLeft int `json:"ladder_steps_left,omitempty"`
|
||||
}
|
||||
|
||||
// Manager handles all docker compose stack operations.
|
||||
@@ -602,8 +610,12 @@ func (m *Manager) ScanStacks() error {
|
||||
// one above (v0.235.0). A missing catalog entry is silent at INFO: an orphaned app has no
|
||||
// catalog template by definition, and warning once per app per scan would be noise.
|
||||
var catImages map[string]string
|
||||
stepsLeft := 0
|
||||
if deployed && !m.cfg.IsProtectedStack(name) {
|
||||
catPath := m.CatalogTemplatePath(name, "docker-compose.yml")
|
||||
if appCfg != nil {
|
||||
stepsLeft = ladderStepsLeft(filepath.Dir(catPath), appCfg.PinnedImages)
|
||||
}
|
||||
if imgs, cerr := ParseComposeImages(catPath); cerr == nil {
|
||||
catImages = imgs
|
||||
} else if m.isDebug() {
|
||||
@@ -622,18 +634,20 @@ func (m *Manager) ScanStacks() error {
|
||||
existing.AppConfig = appCfg
|
||||
existing.TemplateImages = tplImages
|
||||
existing.CatalogImages = catImages
|
||||
existing.LadderStepsLeft = stepsLeft
|
||||
}
|
||||
} else {
|
||||
m.stacks[name] = &Stack{
|
||||
Name: name,
|
||||
Meta: meta,
|
||||
ComposePath: composePath,
|
||||
State: StateNotDeployed,
|
||||
Deployed: deployed,
|
||||
Protected: m.cfg.IsProtectedStack(name),
|
||||
AppConfig: appCfg,
|
||||
TemplateImages: tplImages,
|
||||
CatalogImages: catImages,
|
||||
Name: name,
|
||||
Meta: meta,
|
||||
ComposePath: composePath,
|
||||
State: StateNotDeployed,
|
||||
Deployed: deployed,
|
||||
Protected: m.cfg.IsProtectedStack(name),
|
||||
AppConfig: appCfg,
|
||||
TemplateImages: tplImages,
|
||||
CatalogImages: catImages,
|
||||
LadderStepsLeft: stepsLeft,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,12 +328,17 @@ func (m *Manager) CatalogTemplatePath(appName, filename string) string {
|
||||
// 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 {
|
||||
return m.advancePinTo(name, stackDir, m.CatalogTemplatePath(name, "docker-compose.yml"))
|
||||
}
|
||||
|
||||
// advancePinTo is advancePinToCatalog with the definition named: the catalog's current compose file,
|
||||
// or — on a ladder (v0.268.0, `09` §3 decision 14) — one step's own definition from `steps/`.
|
||||
func (m *Manager) advancePinTo(name, stackDir, src 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
|
||||
|
||||
@@ -18,8 +18,10 @@ import (
|
||||
|
||||
// Slice 3 (v0.235.0) — the pin, the stored definition, and adoption.
|
||||
|
||||
const pinTplOld = "services:\n web:\n image: nextcloud:31.0.14-apache\n"
|
||||
const pinTplNew = "services:\n web:\n image: nextcloud:34.0.1-apache\n"
|
||||
// The volumes block (v0.268.0, R-658): the undo selects the volumes it copies from the DEFINITION,
|
||||
// so the fixture declares the volume the undo tests hold ("nextcloud_db").
|
||||
const pinTplOld = "services:\n web:\n image: nextcloud:31.0.14-apache\nvolumes:\n db:\n"
|
||||
const pinTplNew = "services:\n web:\n image: nextcloud:34.0.1-apache\nvolumes:\n db:\n"
|
||||
|
||||
// newPinManager builds a Manager with one deployed stack and a catalog cache.
|
||||
func newPinManager(t *testing.T, liveCompose, catalogCompose, appYAML string) (*Manager, string) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-651 (v0.268.0) — a removed app left `applied-compose.yml` and `applied-meta/` in its stack dir.
|
||||
// The CONSEQUENCE asserted: after remove + reinstall, the probe the undo would judge the old version
|
||||
// by (savePreUpdateMeta) is the REINSTALL's .felhom.yml — not the removed install's record. (A
|
||||
// reinstall whose pin step fails writes no record of its own, which is when the stale one was read.)
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): delete the R-651 block in RemoveStack. This test then fails at
|
||||
// "the undo would probe with the REMOVED install's .felhom.yml".
|
||||
func TestR651_RemoveDeletesTheAppliedRecord(t *testing.T) {
|
||||
drive := t.TempDir()
|
||||
m, dir, _ := newR442Manager(t, "app", ssdCompose, driveAppYAML(drive), drive)
|
||||
mustWrite(t, AppliedComposePath(dir), ssdCompose)
|
||||
if err := storeAppliedMeta(dir, []byte("# the REMOVED install's probe\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.RemoveStack("app", false, nil); err != nil {
|
||||
t.Fatalf("RemoveStack: %v", err)
|
||||
}
|
||||
for _, p := range []string{AppliedComposePath(dir), filepath.Join(dir, appliedMetaDir)} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
t.Errorf("%s survived the remove (R-651)", p)
|
||||
}
|
||||
}
|
||||
// The catalog mirror stays: it is the template, not the install.
|
||||
if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err != nil {
|
||||
t.Fatalf("the catalog mirror's compose file must stay: %v", err)
|
||||
}
|
||||
// Reinstall (its pin step wrote no record) → the undo's probe source.
|
||||
mustWrite(t, filepath.Join(dir, ".felhom.yml"), "# the REINSTALL's probe\n")
|
||||
md, _ := savePreUpdateMeta(dir)
|
||||
b, _ := os.ReadFile(filepath.Join(md, ".felhom.yml"))
|
||||
if !strings.Contains(string(b), "REINSTALL") {
|
||||
t.Fatalf("the undo would probe with the REMOVED install's .felhom.yml: %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// R-658 — the removal's report counts a declared volume that carries no label (a restored app).
|
||||
// Measured at the 2026-09-23 night's teardown: `volumes_removed: []` over volumes that did go.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make appVolumeSet return projectVolumes alone — the report reads
|
||||
// `[]` again and this test fails.
|
||||
func TestR658_RemoveReportsUnlabelledVolumes(t *testing.T) {
|
||||
drive := t.TempDir()
|
||||
m, _, _ := newR442Manager(t, "app", ssdCompose, driveAppYAML(drive), drive)
|
||||
downDone := false
|
||||
m.execFn = func(name string, args ...string) (string, error) {
|
||||
a := strings.Join(args, " ")
|
||||
switch {
|
||||
case strings.Contains(a, "label=com.docker.compose.project="):
|
||||
return "", nil // restored: no label
|
||||
case a == "volume ls -q":
|
||||
if downDone {
|
||||
return "other_vol\n", nil
|
||||
}
|
||||
return "app_app_cfg\nother_vol\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
// the stub compose (newR442Manager) is the `down`; flip the listing after the first before-read.
|
||||
calls := 0
|
||||
inner := m.execFn
|
||||
m.execFn = func(name string, args ...string) (string, error) {
|
||||
if strings.Join(args, " ") == "volume ls -q" {
|
||||
calls++
|
||||
downDone = calls > 1
|
||||
}
|
||||
return inner(name, args...)
|
||||
}
|
||||
resp, err := m.RemoveStack("app", false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveStack: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(resp.VolumesRemoved, []string{"app_app_cfg"}) {
|
||||
t.Fatalf("volumes_removed = %v, want [app_app_cfg] — an unlabelled volume the remove did delete", resp.VolumesRemoved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-658 (v0.268.0) — after a restore, an app's volumes carry no compose label, and until v0.267.0 the
|
||||
// undo chose the volumes it copies BY THAT LABEL. Measured on 9202 2026-09-23 night, chaos round 9:
|
||||
// `the undo copy will hold 0 named volume(s)`, and the failed update was reported "undone" with the old
|
||||
// binary on the migrated data.
|
||||
|
||||
// TestR658_UndoCopiesUnlabelledVolumes: both of the app's volumes are UNLABELLED (a restore made them);
|
||||
// the new version migrates both and fails; the undo must copy both and put both back.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make appVolumes return copier().ProjectVolumes (the v0.267.0
|
||||
// selector). This test then fails at "copied 0 of 2 declared volumes".
|
||||
func TestR658_UndoCopiesUnlabelledVolumes(t *testing.T) {
|
||||
m, dir, g, _, fc := newUndoManager(t)
|
||||
two := "services:\n web:\n image: nextcloud:31.0.14-apache\nvolumes:\n db:\n data:\n"
|
||||
mustWrite(t, filepath.Join(dir, "docker-compose.yml"), two)
|
||||
fc.vols["nextcloud_data"] = "OLD-FILES"
|
||||
fc.unlabeled = map[string]bool{"nextcloud_db": true, "nextcloud_data": true}
|
||||
inner := m.updateComposeFn
|
||||
m.updateComposeFn = func(d string, env []string, args ...string) (string, error) {
|
||||
out, err := inner(d, env, args...)
|
||||
if args[0] == "up" && fc.vol(undoVol) == "MIGRATED" && fc.vol("nextcloud_data") == "OLD-FILES" {
|
||||
fc.setVol("nextcloud_data", "MIGRATED-FILES")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := waitUpdateDone(t, m, "nextcloud")
|
||||
copied := 0
|
||||
for _, v := range []string{"nextcloud_db", "nextcloud_data"} {
|
||||
if fc.callsHave("copy " + v) {
|
||||
copied++
|
||||
}
|
||||
}
|
||||
if copied != 2 {
|
||||
t.Fatalf("copied %d of 2 declared volumes — the undo selected by label, and a restored app has none (R-658)", copied)
|
||||
}
|
||||
if got := fc.vol(undoVol); got != "OLD" {
|
||||
t.Fatalf("database volume after the undo = %q, want the pre-update OLD", got)
|
||||
}
|
||||
if got := fc.vol("nextcloud_data"); got != "OLD-FILES" {
|
||||
t.Fatalf("second volume after the undo = %q, want OLD-FILES", got)
|
||||
}
|
||||
if st.UpdatePhase != UpdatePhaseUndone || g.held {
|
||||
t.Fatalf("phase=%q held=%v, want undone and no hold", st.UpdatePhase, g.held)
|
||||
}
|
||||
}
|
||||
|
||||
// TestR658_DeclaredVolumeNames — the resolver names volumes the way compose does.
|
||||
func TestR658_DeclaredVolumeNames(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "vikunja")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
body string
|
||||
own, external []string
|
||||
}{
|
||||
{"services: {}\nvolumes:\n files:\n db:\n", []string{"vikunja_db", "vikunja_files"}, nil},
|
||||
{"services: {}\nvolumes:\n db:\n name: custom_db\n", []string{"custom_db"}, nil},
|
||||
{"name: other\nservices: {}\nvolumes:\n db:\n", []string{"other_db"}, nil},
|
||||
{"services: {}\nvolumes:\n db:\n shared:\n external: true\n", []string{"vikunja_db"}, []string{"vikunja_shared"}},
|
||||
{"services: {}\n", nil, nil},
|
||||
}
|
||||
for i, c := range cases {
|
||||
mustWrite(t, filepath.Join(dir, "docker-compose.yml"), c.body)
|
||||
own, ext, err := DeclaredVolumeNames(filepath.Join(dir, "docker-compose.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("case %d: %v", i, err)
|
||||
}
|
||||
if !reflect.DeepEqual(own, c.own) || !reflect.DeepEqual(ext, c.external) {
|
||||
t.Fatalf("case %d: own=%v ext=%v, want %v %v", i, own, ext, c.own, c.external)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestR658_MissingDeclaredVolumeIsSkippedNotInvented — a declared volume Docker does not hold is not
|
||||
// copied (there is nothing to copy) and does not fail the update.
|
||||
func TestR658_MissingDeclaredVolumeIsSkippedNotInvented(t *testing.T) {
|
||||
m, dir, _, _, _ := newUndoManager(t)
|
||||
mustWrite(t, filepath.Join(dir, "docker-compose.yml"), "services:\n web:\n image: nextcloud:31.0.14-apache\nvolumes:\n db:\n never_created:\n")
|
||||
vols, err := m.planUndoCopies("nextcloud", dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(vols, []string{"nextcloud_db"}) {
|
||||
t.Fatalf("planned %v, want only the existing nextcloud_db", vols)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ── The undo (09 §3 decision 15, v0.263.0) ─────────────────────────────────────────────────────────
|
||||
@@ -134,7 +136,11 @@ type UpdateUndone struct {
|
||||
// volumeCopier is the process boundary of the undo's copy. Production is dockerVolumeCopier; tests
|
||||
// inject a fake and never touch docker.
|
||||
type volumeCopier interface {
|
||||
// ProjectVolumes lists the volumes carrying the compose project label. Since v0.268.0 (R-658) it
|
||||
// is a CROSS-CHECK only, logged — never the selector: a restore recreated volumes without it.
|
||||
ProjectVolumes(project string) ([]string, error)
|
||||
// VolumeExists reports whether Docker holds a volume by exactly this name.
|
||||
VolumeExists(vol string) (bool, error)
|
||||
VolumeBytes(vol string) (int64, error)
|
||||
Copy(src, dst, app string) error
|
||||
Complete(copyVol string) bool
|
||||
@@ -166,6 +172,21 @@ func (d dockerVolumeCopier) ProjectVolumes(project string) ([]string, error) {
|
||||
return vols, nil
|
||||
}
|
||||
|
||||
func (d dockerVolumeCopier) VolumeExists(vol string) (bool, error) {
|
||||
// `--filter name=` matches a SUBSTRING, so the listing is only narrowed by it; the exact compare
|
||||
// below is the test.
|
||||
out, err := d.m.execCommand("docker", "volume", "ls", "-q", "--filter", "name="+vol)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(l) == vol {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d dockerVolumeCopier) VolumeBytes(vol string) (int64, error) {
|
||||
out, err := d.m.execCommand("docker", "run", "--rm", "-v", vol+":/v:ro", undoHelperImage, "du", "-sb", "/v")
|
||||
if err != nil {
|
||||
@@ -232,8 +253,8 @@ func undoMsg(key string, args ...interface{}) string { return util.Text("hu", ke
|
||||
|
||||
// planUndoCopies lists the app's named volumes and refuses — before anything moves — when their copy
|
||||
// would breach the disk floor the update already keeps (decision 19's disk limit).
|
||||
func (m *Manager) planUndoCopies(name string) ([]string, error) {
|
||||
vols, err := m.copier().ProjectVolumes(name)
|
||||
func (m *Manager) planUndoCopies(name, dir string) ([]string, error) {
|
||||
vols, err := m.appVolumes(name, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing the app's volumes: %w", err)
|
||||
}
|
||||
@@ -253,6 +274,129 @@ func (m *Manager) planUndoCopies(name string) ([]string, error) {
|
||||
return vols, nil
|
||||
}
|
||||
|
||||
// ── Which volumes are the app's (R-658, v0.268.0) ─────────────────────────────────────────────────
|
||||
//
|
||||
// FOUND 2026-09-23 night by the chaos hour on 9202: the unit restore recreates each named volume with
|
||||
// a bare `docker volume create <name>`, which carries no compose label, and until v0.267.0 the undo
|
||||
// selected the volumes it copies BY THAT LABEL. So after any restore the undo copied NOTHING and
|
||||
// reported the failed update "undone" — the old binary on the new version's migrated data (round 9,
|
||||
// vikunja: `the undo copy will hold 0 named volume(s)`). Measured with a control: the three restored
|
||||
// apps had 0 of 3 / 2 / 2 volumes labelled, the three never restored had all of theirs.
|
||||
//
|
||||
// So the selector is now the app's OWN DEFINITION: the named volumes its compose file declares,
|
||||
// resolved to the names compose gives them, each checked to exist. The label is a cross-check that
|
||||
// is logged and decides nothing. Pinned by TestR658_UndoCopiesUnlabelledVolumes (red-proof: the old
|
||||
// label selector copies 0 of 2).
|
||||
|
||||
// composeVolumesDoc is the part of a compose file that names the app's volumes.
|
||||
type composeVolumesDoc struct {
|
||||
Name string `yaml:"name"`
|
||||
Volumes map[string]*composeVolDef `yaml:"volumes"`
|
||||
}
|
||||
|
||||
type composeVolDef struct {
|
||||
Name string `yaml:"name"`
|
||||
External interface{} `yaml:"external"`
|
||||
}
|
||||
|
||||
func (d *composeVolDef) external() bool {
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
switch v := d.External.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
return true // the legacy `external: {name: …}` form
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DeclaredVolumeNames returns the Docker names of the named volumes a compose file declares, the way
|
||||
// compose names them: the volume's own `name:` when set, else `<project>_<key>`, where the project is
|
||||
// the file's top-level `name:` or, as the manager runs compose, the stack directory's name. External
|
||||
// volumes are not the app's and are left out (returned second, for the log). Sorted.
|
||||
func DeclaredVolumeNames(composePath string) (own, external []string, err error) {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading compose file: %w", err)
|
||||
}
|
||||
var doc composeVolumesDoc
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, nil, fmt.Errorf("parsing compose file %s: %w", composePath, err)
|
||||
}
|
||||
project := strings.TrimSpace(doc.Name)
|
||||
if project == "" {
|
||||
project = filepath.Base(filepath.Dir(composePath))
|
||||
}
|
||||
for key, def := range doc.Volumes {
|
||||
full := project + "_" + key
|
||||
if def != nil && strings.TrimSpace(def.Name) != "" {
|
||||
full = strings.TrimSpace(def.Name)
|
||||
}
|
||||
if def.external() {
|
||||
external = append(external, full)
|
||||
continue
|
||||
}
|
||||
own = append(own, full)
|
||||
}
|
||||
sort.Strings(own)
|
||||
sort.Strings(external)
|
||||
return own, external, nil
|
||||
}
|
||||
|
||||
// appVolumes is the undo's selector: the declared volumes that exist, with the label cross-checked
|
||||
// and every disagreement logged by name.
|
||||
func (m *Manager) appVolumes(name, dir string) ([]string, error) {
|
||||
declared, external, err := DeclaredVolumeNames(ComposePathIn(dir))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(external) > 0 {
|
||||
m.logger.Printf("[INFO] [stacks] update %s: external volume(s) %v are not the app's — never copied", name, external)
|
||||
}
|
||||
var vols []string
|
||||
for _, v := range declared {
|
||||
ok, err := m.copier().VolumeExists(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checking volume %s: %w", v, err)
|
||||
}
|
||||
if !ok {
|
||||
m.logger.Printf("[WARN] [stacks] update %s: the definition declares volume %s but Docker holds none by that name — nothing to copy for it", name, v)
|
||||
continue
|
||||
}
|
||||
vols = append(vols, v)
|
||||
}
|
||||
labeled, lerr := m.copier().ProjectVolumes(filepath.Base(dir))
|
||||
if lerr != nil {
|
||||
m.logger.Printf("[WARN] [stacks] update %s: the label cross-check could not list volumes (%v) — the definition decides anyway", name, lerr)
|
||||
return vols, nil
|
||||
}
|
||||
has := map[string]bool{}
|
||||
for _, v := range labeled {
|
||||
has[v] = true
|
||||
}
|
||||
var unlabeled []string
|
||||
for _, v := range vols {
|
||||
if !has[v] {
|
||||
unlabeled = append(unlabeled, v)
|
||||
}
|
||||
delete(has, v)
|
||||
}
|
||||
if len(unlabeled) > 0 {
|
||||
m.logger.Printf("[WARN] [stacks] update %s: volume(s) %v carry no compose label (recreated by a restore before v0.268.0 — R-658) — copied by name", name, unlabeled)
|
||||
}
|
||||
if len(has) > 0 {
|
||||
var extra []string
|
||||
for v := range has {
|
||||
extra = append(extra, v)
|
||||
}
|
||||
sort.Strings(extra)
|
||||
m.logger.Printf("[INFO] [stacks] update %s: volume(s) %v carry the app's label but the definition does not declare them — not copied", name, extra)
|
||||
}
|
||||
return vols, nil
|
||||
}
|
||||
|
||||
type undoSpaceError struct{ need, free float64 }
|
||||
|
||||
func (e *undoSpaceError) Error() string {
|
||||
|
||||
@@ -34,6 +34,7 @@ type fakeCopier struct {
|
||||
cutOff bool // every copy is made WITHOUT its finished-marker (a copy container killed mid-way)
|
||||
restoreErr error
|
||||
bytes int64
|
||||
unlabeled map[string]bool // volumes Docker holds WITHOUT the compose label (a pre-v0.268.0 restore, R-658)
|
||||
}
|
||||
|
||||
func newFakeCopier(vols map[string]string) *fakeCopier {
|
||||
@@ -46,11 +47,19 @@ func (f *fakeCopier) ProjectVolumes(string) ([]string, error) {
|
||||
defer f.mu.Unlock()
|
||||
var out []string
|
||||
for v := range f.vols {
|
||||
out = append(out, v)
|
||||
if !f.unlabeled[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeCopier) VolumeExists(v string) (bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.vols[v]
|
||||
return ok, nil
|
||||
}
|
||||
func (f *fakeCopier) VolumeBytes(string) (int64, error) { return f.bytes, nil }
|
||||
func (f *fakeCopier) Copy(src, dst, _ string) error {
|
||||
f.mu.Lock()
|
||||
@@ -542,8 +551,13 @@ func TestUndo_EveryPinWriterRecordsTheProbe(t *testing.T) {
|
||||
t.Errorf("%s.%s must call storeAppliedMetaFrom", file, fn)
|
||||
}
|
||||
}
|
||||
if !funcCalls(t, "pin.go", "advancePinToCatalog", "storeAppliedMetaFrom") {
|
||||
t.Error("advancePinToCatalog must call storeAppliedMetaFrom")
|
||||
// v0.268.0: the guarded update pins through advancePinTo (the ladder names the definition);
|
||||
// advancePinToCatalog is its catalog-head wrapper. The writer is advancePinTo.
|
||||
if !funcCalls(t, "pin.go", "advancePinTo", "storeAppliedMetaFrom") {
|
||||
t.Error("advancePinTo must call storeAppliedMetaFrom")
|
||||
}
|
||||
if !funcCalls(t, "update.go", "runGuardedUpdate", "advancePinTo") {
|
||||
t.Error("runGuardedUpdate must pin through advancePinTo")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -292,6 +292,9 @@ func fillHoldReason(g UpdateGuards, st *Stack) {
|
||||
if g != nil && st.Deployed {
|
||||
if h, why := g.HoldFor(st.Name); h {
|
||||
st.HoldReason, held = why, true
|
||||
if nw, ok := g.(holdWholeCopy); ok {
|
||||
st.HoldNoWholeCopy = nw.HoldNoWholeCopy(st.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
// R-480: an update that ended HELD carries the hold's sentence as its UpdateError. Once that hold
|
||||
@@ -304,6 +307,11 @@ func fillHoldReason(g UpdateGuards, st *Stack) {
|
||||
}
|
||||
}
|
||||
|
||||
// holdWholeCopy is the OPTIONAL half of UpdateGuards that says a hold names no copy (R-659).
|
||||
type holdWholeCopy interface {
|
||||
HoldNoWholeCopy(name string) bool
|
||||
}
|
||||
|
||||
// UpdateRefusal is a refusal taken before anything moved. Reason is a stable key for logs and tests;
|
||||
// Message is the customer sentence.
|
||||
type UpdateRefusal struct {
|
||||
@@ -700,6 +708,19 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
fail("update.error.no_guards", "no UpdateGuards wired")
|
||||
return
|
||||
}
|
||||
// v0.268.0 — THE LADDER (`09` §3 decision 14): which definition this ONE press pins. Decided
|
||||
// first, before anything moves, so a step the catalog promises and does not carry refuses here
|
||||
// rather than jumping past it. An unpinned app is left to today's behaviour (advancePinTo no-ops).
|
||||
stepSrc := m.CatalogTemplatePath(name, "docker-compose.yml")
|
||||
if cfg := LoadAppConfig(dir); cfg != nil && len(cfg.PinnedImages) > 0 {
|
||||
step, serr := nextLadderStep(filepath.Dir(stepSrc), cfg.PinnedImages)
|
||||
if serr != nil {
|
||||
fail("update.error.pin_failed", "update ladder: "+serr.Error())
|
||||
return
|
||||
}
|
||||
stepSrc = step.Source
|
||||
m.logger.Printf("[INFO] [stacks] update %s: ladder — %s", name, step.Why)
|
||||
}
|
||||
// R-475: the precondition is a copy on ANY tier, chosen in the order 2, 1, 3, and the age rule
|
||||
// applies to whichever tier is chosen. The first FRESH copy wins — not merely the first copy — so a
|
||||
// stale second-drive mirror never forces a backup while the app's own unit is minutes old.
|
||||
@@ -743,7 +764,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
|
||||
// v0.263.0 — the undo's copy is PLANNED here, before anything moves: its size against the disk
|
||||
// floor (decision 19's limit). The copy itself is taken after the pull, where the app stops anyway.
|
||||
undoVols, perr := m.planUndoCopies(name)
|
||||
undoVols, perr := m.planUndoCopies(name, dir)
|
||||
if perr != nil {
|
||||
if se, ok := perr.(*undoSpaceError); ok {
|
||||
fail("err.stacks.update_undo_space", "undo copy: "+perr.Error(), se.need, se.free, updateDiskFloorGiB)
|
||||
@@ -792,7 +813,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
fail("update.error.journal_failed", "journal write failed")
|
||||
return
|
||||
}
|
||||
if err := m.advancePinToCatalog(name, dir); err != nil {
|
||||
if err := m.advancePinTo(name, dir, stepSrc); err != nil {
|
||||
m.pinBack(name, dir, entry)
|
||||
fail("update.error.pin_failed", "advancing the pin: "+err.Error())
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user