D49 = A follow-ups: (A) the undo copy is released only after a stable start — 3 min of running, healthy, no restart, one rule for the first verdict and the late-start watch; a watch ends only for a restore begun after it; (B, R-1279) the .fab import downloads first with the restores' D48 helper, before it removes anything. Red-proved. Commit only.
gates / gates (push) Successful in 1m26s

This commit is contained in:
2026-10-25 12:45:33 +01:00
parent e8d2151a77
commit 7720439440
16 changed files with 549 additions and 27 deletions
+36
View File
@@ -2093,6 +2093,10 @@ func main() {
// this the export path would be the uncovered sibling of two covered ones, which is how a reader
// concludes the whole class is handled (§2.2).
appExporter.SetStopGuard(exportStopGuard{g: appStopGuard})
// R-1279 (D48 for the import): the import downloads first, with the restores' helper and sentences.
if backupMgr != nil {
appExporter.SetImagePreparer(backupMgr.PrepareRestoreImages)
}
apiRouter.SetDebug(cfg.Logging.Level == "debug")
// --- Initialize web server ---
@@ -3439,6 +3443,38 @@ func (a *stackAdapter) StartStackServices(name string, services []string) error
return a.mgr.StartStackServices(name, services)
}
// ContainerSamples (R-1270 follow-up, fix A) reads each container of the stack: running, its RestartCount, its health
// ("" without a healthcheck) — the stable-start rule's sample (backup.containerSampler). Read-only docker calls.
func (a *stackAdapter) ContainerSamples(name string) ([]backup.ContainerSample, error) {
out, err := dockerexec.Command("docker", "ps", "-a", "-q", "--no-trunc", "--filter", "label=com.docker.compose.project="+name).Output()
if err != nil {
return nil, err
}
ids := strings.Fields(string(out))
if len(ids) == 0 {
return nil, nil
}
args := append([]string{"inspect", "-f", "{{.Id}} {{.State.Status}} {{.RestartCount}} {{if .State.Health}}{{.State.Health.Status}}{{end}}"}, ids...)
out, err = dockerexec.Command("docker", args...).Output()
if err != nil {
return nil, err
}
var res []backup.ContainerSample
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
n, _ := strconv.Atoi(f[2])
c := backup.ContainerSample{ID: f[0], Running: f[1] == "running", RestartCount: n}
if len(f) > 3 {
c.Health = f[3]
}
res = append(res, c)
}
return res, nil
}
// RefreshAndIsRunning forces a docker ps scan before checking state.
// Called during post-restore health check (~every 5s for up to 90s).
// Full refresh is acceptable here since restores are rare operations.
@@ -0,0 +1,22 @@
package main
import (
"os"
"strings"
"testing"
)
// R-1279 — the `.fab` import downloads first only if main.go passes the restores' helper in; an unwired exporter keeps
// the old order (volumes removed before the pull). Seam-built-but-never-wired is a recorded class (REUSE.md).
func TestR1279_MainWiresTheImportsDownloadFirst(t *testing.T) {
b, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), "appExporter.SetImagePreparer(backupMgr.PrepareRestoreImages)") {
t.Fatal("main.go must wire the import's download-first helper (SetImagePreparer)")
}
if !strings.Contains(string(b), "func (a *stackAdapter) ContainerSamples(") {
t.Fatal("main.go's stack adapter must read container samples for the stable-start rule (fix A)")
}
}