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

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:
2026-09-24 08:15:27 +02:00
parent 80e6ad8c47
commit 206b0357d1
36 changed files with 2201 additions and 40 deletions
+44 -2
View File
@@ -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{}