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
+9
View File
@@ -103,6 +103,9 @@ type Exporter struct {
// BOTH packages write ONE marker file — an exporter with its own file would be a second writer
// racing the same recovery. Nil = not wired (tests): the export runs exactly as it did before.
stopGuard appStopGuard
// prepareImages (R-1279, D48 for the import): resolves and pulls every image of the definition that will run,
// BEFORE the import removes anything; the returned func releases the image-work hold. Nil = unwired (tests).
prepareImages func(app, composePath string) (func(), error)
mu sync.Mutex
activeJob *Job
@@ -137,6 +140,12 @@ func NewExporter(provider ExportStackProvider, logger *log.Logger, version strin
// SetStopGuard wires the app-stop crash marker. INIT-ONLY — call once at startup, before any export.
func (e *Exporter) SetStopGuard(g appStopGuard) { e.stopGuard = g }
// SetImagePreparer wires the D48 download-first helper (backup.Manager.PrepareRestoreImages — the restores' own, passed
// in so this package needs no import of the backup package). INIT-ONLY, main.go.
func (e *Exporter) SetImagePreparer(fn func(app, composePath string) (func(), error)) {
e.prepareImages = fn
}
// stopGuardBegin records the app-stop marker before an export stops an app. An unwired guard is a
// no-op (pre-v0.189.0 behaviour), never an error — a test exporter must not be forced to have one.
func (e *Exporter) stopGuardBegin(stackName string) error {
@@ -0,0 +1,94 @@
package appexport
import (
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-1279 (brief 2026-10-26 fix B) — the `.fab` import downloads first. Before (`e8d2151`): step 1 removed the RUNNING
// app's volumes, and the first pull came later at the database start, so a version that could not be downloaded left
// the app gone. Now the D48 helper runs before step 1 and a refusal changes nothing.
// COMPANION RED-PROOF (REPORT.md): drop the prepareImages call in restore.go → TestR1279_* fails (the volumes are
// removed and the app is stopped before anything is pulled).
func r1279Bundle(t *testing.T) (fab, stackDir, stacksDir string) {
t.Helper()
tree := t.TempDir()
os.MkdirAll(filepath.Join(tree, "config"), 0755)
os.WriteFile(filepath.Join(tree, "config", "docker-compose.yml"), []byte("services:\n kuma:\n image: louislam/uptime-kuma:0.0.0-missing\n"), 0644)
man := &Manifest{Version: ManifestVersion, AppName: "kuma", DisplayName: "Kuma", ConfigFiles: []string{"docker-compose.yml"}}
data, err := man.Marshal()
if err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(tree, "manifest.json"), data, 0644)
fab = filepath.Join(t.TempDir(), "kuma.fab")
if err := createTarGz(fab, tree); err != nil {
t.Fatal(err)
}
stacksDir = t.TempDir()
stackDir = filepath.Join(stacksDir, "kuma")
os.MkdirAll(stackDir, 0755)
os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("LIVE"), 0644)
return fab, stackDir, stacksDir
}
func TestR1279_AnUnpullableImageRefusesTheImportBeforeAnythingIsRemoved(t *testing.T) {
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { return "", nil })
fab, stackDir, stacksDir := r1279Bundle(t)
prov := &rtProvider{stackDir: stackDir, stacksDir: stacksDir, deployed: true, running: true, volumes: []string{"kuma_data"}}
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
var asked string
gone := util.MsgErrorf(errors.New("gone"), "err.backup.restore_image_gone", "uptime-kuma:0.0.0-missing")
e.SetImagePreparer(func(app, compose string) (func(), error) {
b, _ := os.ReadFile(compose)
asked = string(b)
return nil, gone
})
if err := e.StartImport(ImportRequest{FABPath: fab}); err != nil {
t.Fatalf("StartImport: %v", err)
}
msg := jobErr(waitJob(t, e))
if !strings.Contains(asked, "0.0.0-missing") {
t.Fatalf("the helper must be asked about the BUNDLE's definition (the one that will run), got %q", asked)
}
if !strings.Contains(msg, "nem lehet letölteni") || !strings.Contains(msg, "nem nyúltunk") {
t.Fatalf("the household reads the D48 sentence, got %q", msg)
}
if prov.stopped != 0 || prov.removed != 0 || prov.started || len(*calls) != 0 {
t.Fatalf("a refused import must change nothing: stopped=%d removed=%d started=%t docker=%v", prov.stopped, prov.removed, prov.started, *calls)
}
if b, _ := os.ReadFile(filepath.Join(stackDir, "docker-compose.yml")); string(b) != "LIVE" {
t.Fatalf("the app's files were changed: %q", b)
}
}
// A pullable image: asked first, then the import goes on as before (the running app's volumes removed after the pull).
func TestR1279_APullableImageIsAskedFirstAndTheImportGoesOn(t *testing.T) {
swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { return "", nil })
fab, stackDir, stacksDir := r1279Bundle(t)
prov := &rtProvider{stackDir: stackDir, stacksDir: stacksDir, deployed: true, running: true, volumes: []string{"kuma_data"}}
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
removedAtAsk, released := -1, false
e.SetImagePreparer(func(string, string) (func(), error) {
removedAtAsk = prov.removed
return func() { released = true }, nil
})
if err := e.StartImport(ImportRequest{FABPath: fab}); err != nil {
t.Fatalf("StartImport: %v", err)
}
waitJob(t, e)
if removedAtAsk != 0 || prov.removed != 1 {
t.Fatalf("the pull must come BEFORE the running app's volumes are removed: removed at ask %d, after %d", removedAtAsk, prov.removed)
}
if !released {
t.Fatal("the image-work hold must be released when the import ends")
}
}
+21
View File
@@ -338,6 +338,27 @@ func (e *Exporter) executeImport(req ImportRequest, job *Job) {
return
}
// R-1279 (D48 for the import, brief 2026-10-26 fix B): DOWNLOAD FIRST. Every image of the definition that will run
// (the bundle's compose; the live one when the bundle carries none) is on the box BEFORE step 1 removes the running
// app's volumes — or the import is refused here with the app untouched, in the restores' two sentences (a version
// its maker deleted / a busy or unreachable registry). Measured before: the volumes went first and the pull came at
// the database start, so a version that could not be downloaded left the app gone.
if e.prepareImages != nil {
runCompose := filepath.Join(tmpDir, "config", "docker-compose.yml")
if _, serr := os.Stat(runCompose); serr != nil {
if p, ok := e.provider.GetStackComposePath(manifest.AppName); ok {
runCompose = p
}
}
release, perr := e.prepareImages(manifest.AppName, runCompose)
if perr != nil {
e.logger.Printf("[WARN] Import: REFUSED before anything was touched — an image of %s could not be downloaded: %v (R-1279)", manifest.AppName, perr)
e.failJob(job, step, util.ErrText("hu", perr))
return
}
defer release()
}
e.debugf("step 0 (open bundle) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")