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
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:
@@ -5,11 +5,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ── The undo (09 §3 decision 15, v0.263.0) ─────────────────────────────────────────────────────────
|
||||
@@ -134,7 +136,11 @@ type UpdateUndone struct {
|
||||
// volumeCopier is the process boundary of the undo's copy. Production is dockerVolumeCopier; tests
|
||||
// inject a fake and never touch docker.
|
||||
type volumeCopier interface {
|
||||
// ProjectVolumes lists the volumes carrying the compose project label. Since v0.268.0 (R-658) it
|
||||
// is a CROSS-CHECK only, logged — never the selector: a restore recreated volumes without it.
|
||||
ProjectVolumes(project string) ([]string, error)
|
||||
// VolumeExists reports whether Docker holds a volume by exactly this name.
|
||||
VolumeExists(vol string) (bool, error)
|
||||
VolumeBytes(vol string) (int64, error)
|
||||
Copy(src, dst, app string) error
|
||||
Complete(copyVol string) bool
|
||||
@@ -166,6 +172,21 @@ func (d dockerVolumeCopier) ProjectVolumes(project string) ([]string, error) {
|
||||
return vols, nil
|
||||
}
|
||||
|
||||
func (d dockerVolumeCopier) VolumeExists(vol string) (bool, error) {
|
||||
// `--filter name=` matches a SUBSTRING, so the listing is only narrowed by it; the exact compare
|
||||
// below is the test.
|
||||
out, err := d.m.execCommand("docker", "volume", "ls", "-q", "--filter", "name="+vol)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(l) == vol {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d dockerVolumeCopier) VolumeBytes(vol string) (int64, error) {
|
||||
out, err := d.m.execCommand("docker", "run", "--rm", "-v", vol+":/v:ro", undoHelperImage, "du", "-sb", "/v")
|
||||
if err != nil {
|
||||
@@ -232,8 +253,8 @@ func undoMsg(key string, args ...interface{}) string { return util.Text("hu", ke
|
||||
|
||||
// planUndoCopies lists the app's named volumes and refuses — before anything moves — when their copy
|
||||
// would breach the disk floor the update already keeps (decision 19's disk limit).
|
||||
func (m *Manager) planUndoCopies(name string) ([]string, error) {
|
||||
vols, err := m.copier().ProjectVolumes(name)
|
||||
func (m *Manager) planUndoCopies(name, dir string) ([]string, error) {
|
||||
vols, err := m.appVolumes(name, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing the app's volumes: %w", err)
|
||||
}
|
||||
@@ -253,6 +274,129 @@ func (m *Manager) planUndoCopies(name string) ([]string, error) {
|
||||
return vols, nil
|
||||
}
|
||||
|
||||
// ── Which volumes are the app's (R-658, v0.268.0) ─────────────────────────────────────────────────
|
||||
//
|
||||
// FOUND 2026-09-23 night by the chaos hour on 9202: the unit restore recreates each named volume with
|
||||
// a bare `docker volume create <name>`, which carries no compose label, and until v0.267.0 the undo
|
||||
// selected the volumes it copies BY THAT LABEL. So after any restore the undo copied NOTHING and
|
||||
// reported the failed update "undone" — the old binary on the new version's migrated data (round 9,
|
||||
// vikunja: `the undo copy will hold 0 named volume(s)`). Measured with a control: the three restored
|
||||
// apps had 0 of 3 / 2 / 2 volumes labelled, the three never restored had all of theirs.
|
||||
//
|
||||
// So the selector is now the app's OWN DEFINITION: the named volumes its compose file declares,
|
||||
// resolved to the names compose gives them, each checked to exist. The label is a cross-check that
|
||||
// is logged and decides nothing. Pinned by TestR658_UndoCopiesUnlabelledVolumes (red-proof: the old
|
||||
// label selector copies 0 of 2).
|
||||
|
||||
// composeVolumesDoc is the part of a compose file that names the app's volumes.
|
||||
type composeVolumesDoc struct {
|
||||
Name string `yaml:"name"`
|
||||
Volumes map[string]*composeVolDef `yaml:"volumes"`
|
||||
}
|
||||
|
||||
type composeVolDef struct {
|
||||
Name string `yaml:"name"`
|
||||
External interface{} `yaml:"external"`
|
||||
}
|
||||
|
||||
func (d *composeVolDef) external() bool {
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
switch v := d.External.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
return true // the legacy `external: {name: …}` form
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DeclaredVolumeNames returns the Docker names of the named volumes a compose file declares, the way
|
||||
// compose names them: the volume's own `name:` when set, else `<project>_<key>`, where the project is
|
||||
// the file's top-level `name:` or, as the manager runs compose, the stack directory's name. External
|
||||
// volumes are not the app's and are left out (returned second, for the log). Sorted.
|
||||
func DeclaredVolumeNames(composePath string) (own, external []string, err error) {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading compose file: %w", err)
|
||||
}
|
||||
var doc composeVolumesDoc
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, nil, fmt.Errorf("parsing compose file %s: %w", composePath, err)
|
||||
}
|
||||
project := strings.TrimSpace(doc.Name)
|
||||
if project == "" {
|
||||
project = filepath.Base(filepath.Dir(composePath))
|
||||
}
|
||||
for key, def := range doc.Volumes {
|
||||
full := project + "_" + key
|
||||
if def != nil && strings.TrimSpace(def.Name) != "" {
|
||||
full = strings.TrimSpace(def.Name)
|
||||
}
|
||||
if def.external() {
|
||||
external = append(external, full)
|
||||
continue
|
||||
}
|
||||
own = append(own, full)
|
||||
}
|
||||
sort.Strings(own)
|
||||
sort.Strings(external)
|
||||
return own, external, nil
|
||||
}
|
||||
|
||||
// appVolumes is the undo's selector: the declared volumes that exist, with the label cross-checked
|
||||
// and every disagreement logged by name.
|
||||
func (m *Manager) appVolumes(name, dir string) ([]string, error) {
|
||||
declared, external, err := DeclaredVolumeNames(ComposePathIn(dir))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(external) > 0 {
|
||||
m.logger.Printf("[INFO] [stacks] update %s: external volume(s) %v are not the app's — never copied", name, external)
|
||||
}
|
||||
var vols []string
|
||||
for _, v := range declared {
|
||||
ok, err := m.copier().VolumeExists(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checking volume %s: %w", v, err)
|
||||
}
|
||||
if !ok {
|
||||
m.logger.Printf("[WARN] [stacks] update %s: the definition declares volume %s but Docker holds none by that name — nothing to copy for it", name, v)
|
||||
continue
|
||||
}
|
||||
vols = append(vols, v)
|
||||
}
|
||||
labeled, lerr := m.copier().ProjectVolumes(filepath.Base(dir))
|
||||
if lerr != nil {
|
||||
m.logger.Printf("[WARN] [stacks] update %s: the label cross-check could not list volumes (%v) — the definition decides anyway", name, lerr)
|
||||
return vols, nil
|
||||
}
|
||||
has := map[string]bool{}
|
||||
for _, v := range labeled {
|
||||
has[v] = true
|
||||
}
|
||||
var unlabeled []string
|
||||
for _, v := range vols {
|
||||
if !has[v] {
|
||||
unlabeled = append(unlabeled, v)
|
||||
}
|
||||
delete(has, v)
|
||||
}
|
||||
if len(unlabeled) > 0 {
|
||||
m.logger.Printf("[WARN] [stacks] update %s: volume(s) %v carry no compose label (recreated by a restore before v0.268.0 — R-658) — copied by name", name, unlabeled)
|
||||
}
|
||||
if len(has) > 0 {
|
||||
var extra []string
|
||||
for v := range has {
|
||||
extra = append(extra, v)
|
||||
}
|
||||
sort.Strings(extra)
|
||||
m.logger.Printf("[INFO] [stacks] update %s: volume(s) %v carry the app's label but the definition does not declare them — not copied", name, extra)
|
||||
}
|
||||
return vols, nil
|
||||
}
|
||||
|
||||
type undoSpaceError struct{ need, free float64 }
|
||||
|
||||
func (e *undoSpaceError) Error() string {
|
||||
|
||||
Reference in New Issue
Block a user