controller v0.242.0: a removed app is listed with its kept backup; five small ones (R-487 R-491 R-490 R-489 R-476 R-456)
gates / gates (push) Successful in 14s
gates / gates (push) Successful in 14s
R-487: the local backup lists are keyed on the drives, not on what is deployed — a removed app whose unit was kept is listed with the restore that reinstalls it, the picker answers for it, and the restore opens the unit where it sits. R-491: a removal clears the app's update hold. R-490: /api/system/info reaches the API router and reads the default storage path. R-489: volumes_removed is the real before/after difference, [] when none. R-476: a Tier-2 copy is dated by its data, not its manifest. R-456: the boot-orphan rule is pinned. Every fix red-proofed.
This commit is contained in:
@@ -478,6 +478,10 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
|
||||
// Step 2: Run docker compose down --volumes (keep images for potential redeploy)
|
||||
env := m.stackEnv(stackDir)
|
||||
// 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)
|
||||
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))
|
||||
@@ -487,12 +491,10 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err)
|
||||
}
|
||||
|
||||
// Step 3: Identify removed volumes from compose output
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") {
|
||||
resp.VolumesRemoved = append(resp.VolumesRemoved, line)
|
||||
}
|
||||
// Step 3: the volumes that are gone now — `[]` when none, never null (R-489).
|
||||
resp.VolumesRemoved = removedVolumes(volsBefore, m.projectVolumes(name))
|
||||
if len(resp.VolumesRemoved) > 0 {
|
||||
m.logger.Printf("[INFO] [stacks] RemoveStack %s: removed volume(s) %v", name, resp.VolumesRemoved)
|
||||
}
|
||||
|
||||
// Step 4: Handle HDD data
|
||||
@@ -848,3 +850,34 @@ func getDirSizeBytes(path string) int64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// projectVolumes lists the named volumes Docker holds for a compose project (by its project label).
|
||||
// A listing failure reads as no volumes, so a removal never fails on bookkeeping.
|
||||
func (m *Manager) projectVolumes(project string) []string {
|
||||
out, err := m.execCommand("docker", "volume", "ls", "--filter", "label=com.docker.compose.project="+project, "--format", "{{.Name}}")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var vols []string
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
vols = append(vols, l)
|
||||
}
|
||||
}
|
||||
return vols
|
||||
}
|
||||
|
||||
// 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{}
|
||||
for _, v := range after {
|
||||
still[v] = true
|
||||
}
|
||||
removed := []string{}
|
||||
for _, v := range before {
|
||||
if !still[v] {
|
||||
removed = append(removed, v)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -1610,3 +1610,9 @@ func (m *Manager) getCatalogTemplateSlugs() map[string]bool {
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
// AggregateStateForTest exposes aggregateState with every member supervised, to tests outside this
|
||||
// package — R-456's pin lives in bootrecon.
|
||||
func AggregateStateForTest(containers []ContainerInfo) ContainerState {
|
||||
return aggregateState(containers, func(string) string { return "unless-stopped" })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-489 — the removal reports the volumes it removed, and `[]` when none — never `null`.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make removedVolumes return nil for an empty result — the JSON
|
||||
// reads null and the second assertion fails.
|
||||
func TestR489_RemovedVolumesIsADifferenceAndNeverNull(t *testing.T) {
|
||||
got := removedVolumes([]string{"app_data", "app_db", "app_cache"}, []string{"app_cache"})
|
||||
if strings.Join(got, ",") != "app_data,app_db" {
|
||||
t.Errorf("before minus after = %v", got)
|
||||
}
|
||||
b, _ := json.Marshal(RemoveResponse{VolumesRemoved: removedVolumes(nil, nil)})
|
||||
if !strings.Contains(string(b), `"volumes_removed":[]`) {
|
||||
t.Errorf("no volumes must read as [] — got %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR489_ProjectVolumesReadsTheDockerListing(t *testing.T) {
|
||||
m := &Manager{}
|
||||
var seen []string
|
||||
m.execFn = func(name string, args ...string) (string, error) {
|
||||
seen = append([]string{name}, args...)
|
||||
return "app_data\napp_db\n\n", nil
|
||||
}
|
||||
got := m.projectVolumes("app")
|
||||
if strings.Join(got, ",") != "app_data,app_db" {
|
||||
t.Errorf("volumes = %v", got)
|
||||
}
|
||||
if !strings.Contains(strings.Join(seen, " "), "label=com.docker.compose.project=app") {
|
||||
t.Errorf("must filter by the compose project label; ran %v", seen)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user