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") } }