Files
felhom-controller/controller/internal/stacks/r658_undo_volumes_test.go
T

99 lines
3.9 KiB
Go

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)
}
}