R-1187 (c): refuse a .fab import over an installed app that is not running (it merged old and bundle data); R-1189 (d): the off-site proof + integrity check run after a catch-up; unreleased
gates / gates (push) Successful in 1m54s

This commit is contained in:
2026-10-22 17:27:12 +02:00
parent 6a17c95815
commit d5f88a2182
7 changed files with 175 additions and 5 deletions
@@ -0,0 +1,51 @@
package appexport
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
// R-1187 (c): a bundle imported over an app that is installed but NOT running is refused before anything is written —
// before the fix it „overwrote config in place" and merged the old volume data with the bundle's (proven on 9202
// 2026-10-22: a marker written after the export survived the import, reported as success).
// RED-PROOF: without the refusal the job succeeds over the stopped app — seen failing 2026-10-22.
func TestR1187c_AStoppedAppIsNotImportedOver(t *testing.T) {
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { return "", nil })
tree := t.TempDir()
os.MkdirAll(filepath.Join(tree, "config"), 0755)
os.WriteFile(filepath.Join(tree, "config", "docker-compose.yml"), []byte("services: {}\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)
prov := &rtProvider{stackDir: stackDir, stacksDir: stacksDir, deployed: true, running: false}
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
if err := e.StartImport(ImportRequest{FABPath: fab}); err != nil {
t.Fatalf("StartImport: %v", err)
}
msg := jobErr(waitJob(t, e))
if msg == "" || !strings.Contains(msg, "nem fut") {
t.Fatalf("an import over a stopped app must be refused with the reason, got %q", msg)
}
if b, _ := os.ReadFile(filepath.Join(stackDir, "docker-compose.yml")); string(b) != "LIVE" {
t.Fatalf("the app's files were changed: %q", b)
}
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)
}
}
+12
View File
@@ -358,6 +358,18 @@ func (e *Exporter) executeImport(req ImportRequest, job *Job) {
}
e.debugf("existing stack: deployed=%v running=%v", isDeployed, isRunning)
// R-1187 (c) (data-loss hunt 2026-10-20; proven on 9202 2026-10-22): an import over an app that is installed but
// NOT running used to „overwrite config in place" — its volumes were never removed, and `docker cp` only adds and
// overwrites, so the result was the old data MERGED with the bundle's (a stale SQLite -wal beside the bundle's .db
// is a corruption shape), reported as success. Only a running app's volumes are removed first. So this import is
// refused here, before anything is written; the household starts the app (or removes it) and imports again.
// Pinned by TestR1187c_AStoppedAppIsNotImportedOver.
if isDeployed && !isRunning {
e.logger.Printf("[WARN] Import: refused — %s is installed but not running; importing over it would merge the old data with the bundle's (R-1187 c)", manifest.AppName)
e.failJob(job, step, "Az alkalmazás telepítve van, de nem fut, ezért a csomagot nem töltjük vissza: a régi adatai összekeveredhetnének a csomagéval. Az alkalmazás érintetlen. Indítsd el (vagy távolítsd el), és próbáld újra.")
return
}
if isDeployed {
if isRunning {
e.logger.Printf("[INFO] Import: removing existing stack %s (with volumes)", manifest.AppName)