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
gates / gates (push) Successful in 1m26s
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user