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
+41 -2
View File
@@ -139,19 +139,32 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
var restored int
var failed []string
composeVer := ""
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar") {
continue
}
volName := strings.TrimSuffix(entry.Name(), ".tar")
if composeVer == "" {
composeVer = composeVersionShort()
}
m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s", volName, stackName)
// Remove existing volume (ignore errors — may not exist)
dockerexec.Command("docker", "volume", "rm", "-f", volName).Run()
// Create fresh volume
if out, err := dockerexec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil {
// Create fresh volume — WITH the labels compose gives its own volumes (R-658, v0.268.0). A bare
// `volume create` made a volume the update's undo (until v0.267.0) could not find, and one
// compose warns it "was not created by Docker Compose". The config-hash label is deliberately
// NOT set: compose only compares it when present, and a guessed hash would make it offer to
// recreate (empty) the volume.
args := append([]string{"volume", "create"}, composeVolumeLabelArgs(stackName, volName, composeVer)...)
if len(args) == 2 {
m.logger.Printf("[WARN] [backup] volume %s does not carry the %s_ prefix — created WITHOUT compose labels (the undo finds it by the app's definition anyway)", volName, stackName)
}
args = append(args, volName)
if out, err := dockerexec.Command("docker", args...).CombinedOutput(); err != nil {
m.logger.Printf("[ERROR] [backup] Failed to create volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err)
failed = append(failed, volName)
continue
@@ -215,3 +228,29 @@ func (m *Manager) waitForHealthy(stackName string, timeout time.Duration) error
}
return fmt.Errorf("stack %s did not reach running state within %s after restore", stackName, timeout)
}
// composeVolumeLabelArgs returns the `--label` arguments that make a restored volume look like one
// compose created for this project: project, volume key and compose version. The key is the part of
// the Docker name after `<project>_`, which is how compose names a volume without its own `name:` —
// every catalog template today (R-658, measured 2026-09-24). A name without that prefix gets no labels
// rather than a guessed key. The version label is left out when the version could not be read.
func composeVolumeLabelArgs(project, volName, composeVersion string) []string {
key := strings.TrimPrefix(volName, project+"_")
if key == volName || key == "" {
return nil
}
args := []string{"--label", "com.docker.compose.project=" + project, "--label", "com.docker.compose.volume=" + key}
if composeVersion != "" {
args = append(args, "--label", "com.docker.compose.version="+composeVersion)
}
return args
}
// composeVersionShort is `docker compose version --short`, "" when it cannot be read.
func composeVersionShort() string {
out, err := dockerexec.Command("docker", "compose", "version", "--short").Output()
if err != nil {
return ""
}
return strings.TrimPrefix(strings.TrimSpace(string(out)), "v")
}