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