controller: .fab volume legs stream via docker cp (fixes the containerized path-strand data loss, IA finding 1 HIGH) — zero shared paths both deployment shapes; export fails LOUD on any missing/empty claimed tar (no hollow bundles); import validates BEFORE it destroys (hollow bundle → app untouched); docker_run_volume_path_gate.py extinguishes the class (Tier-1/2 mounts documented host-visible)

Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
This commit is contained in:
2026-07-13 10:42:02 +02:00
parent 811a0ef7a7
commit 466f42708e
5 changed files with 489 additions and 46 deletions
+65 -20
View File
@@ -326,6 +326,16 @@ func (e *Exporter) executeImport(req ImportRequest, job *Job) {
job.mu.Unlock()
e.logger.Printf("[INFO] Import: opening bundle for %s (%s)", manifest.AppName, manifest.DisplayName)
// v0.125.0 validate-before-destroy (scenario C): every manifest-claimed data tar must be
// present and non-empty BEFORE the app is stopped and BEFORE any volume is removed. Hollow
// bundles (a containerized v<=0.124.0 exporter stranded the tars host-side) land HERE — the
// pre-fix order wiped the volumes first and only then discovered the emptiness.
if err := validateBundleData(tmpDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("A csomag hiányos — az importálás el sem indult, a meglévő alkalmazás érintetlen. (%v) A csomagot valószínűleg egy régebbi (≤0.124.0), konténerben futó vezérlő exportálta — készíts friss exportot.", err))
return
}
e.debugf("step 0 (open bundle) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
@@ -597,9 +607,9 @@ func (e *Exporter) restoreHDDData(tmpDir string, manifest *Manifest, composePath
tarPath := filepath.Join(hddDir, subdir+".tar")
tarInfo, err := os.Stat(tarPath)
if err != nil {
e.logger.Printf("[WARN] Import: HDD tar not found: %s", tarPath)
e.debugf("restoreHDDData: tar not found: %s", tarPath)
continue
// v0.125.0: a claimed-but-absent tar is an ERROR (validateBundleData already refused
// it pre-destroy; this is defense in depth, not a reachable soft path).
return fmt.Errorf("HDD tar missing from bundle: %s", subdir+".tar")
}
e.debugf("restoreHDDData: subdir=%s tarSize=%s", subdir, humanizeBytes(tarInfo.Size()))
@@ -670,7 +680,52 @@ func resolveHDDMounts(composePath string, env map[string]string) []string {
return mounts
}
// restoreVolumeData recreates Docker named volumes from bundle tarballs.
// validateBundleData asserts every manifest-claimed data tar exists non-empty in the extracted
// bundle (v0.125.0, scenario C). Pure read — called before ANY destructive import step.
func validateBundleData(tmpDir string, manifest *Manifest) error {
for _, v := range manifest.VolumeNames {
if err := ValidateSegment("volume_name", v); err != nil {
return err
}
fi, err := os.Stat(filepath.Join(tmpDir, "data", "volumes", v+".tar"))
if err != nil || fi.Size() == 0 {
return fmt.Errorf("a(z) %q kötet adata hiányzik a csomagból", v)
}
}
for _, s := range manifest.HDDSubdirs {
if err := ValidateSegment("hdd_subdir", s); err != nil {
return err
}
fi, err := os.Stat(filepath.Join(tmpDir, "data", "hdd", s+".tar"))
if err != nil || fi.Size() == 0 {
return fmt.Errorf("a(z) %q adatkönyvtár tartalma hiányzik a csomagból", s)
}
}
return nil
}
// importVolumeTar streams tarPath into the (existing) volume via `docker cp - <cid>:/vol` —
// zero shared paths, correct on bare metal AND under the containerized controller (the
// docker-run -v population was the v0.124.0 strand's import half).
func (e *Exporter) importVolumeTar(volName, tarPath string) error {
return e.withVolumeHelper(volName, func(cid string) error {
f, err := os.Open(tarPath)
if err != nil {
return fmt.Errorf("opening %s: %w", tarPath, err)
}
defer f.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if stderr, err := dockerExec(ctx, f, nil, "cp", "-", cid+":/vol"); err != nil {
return fmt.Errorf("streaming into volume %s: %s — %w", volName, stderr, err)
}
return nil
})
}
// restoreVolumeData recreates Docker named volumes from bundle tarballs. v0.125.0: a missing
// tar is an ERROR (defense in depth behind validateBundleData), and population streams via
// docker cp instead of a docker-run -v host mount.
func (e *Exporter) restoreVolumeData(tmpDir string, manifest *Manifest) error {
volDir := filepath.Join(tmpDir, "data", "volumes")
@@ -683,34 +738,24 @@ func (e *Exporter) restoreVolumeData(tmpDir string, manifest *Manifest) error {
tarPath := filepath.Join(volDir, volName+".tar")
tarInfo, err := os.Stat(tarPath)
if err != nil {
e.logger.Printf("[WARN] Import: volume tar not found: %s", tarPath)
e.debugf("restoreVolumeData: tar not found: %s", tarPath)
continue
return fmt.Errorf("volume tar missing from bundle: %s", volName+".tar")
}
e.debugf("restoreVolumeData: volume=%s tarSize=%s", volName, humanizeBytes(tarInfo.Size()))
// Create the Docker volume
e.debugf("restoreVolumeData: creating docker volume %s", volName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
out, err := exec.CommandContext(ctx, "docker", "volume", "create", volName).CombinedOutput()
stderr, err := dockerExec(ctx, nil, nil, "volume", "create", volName)
cancel()
if err != nil {
return fmt.Errorf("creating volume %s: %s — %w", volName, strings.TrimSpace(string(out)), err)
return fmt.Errorf("creating volume %s: %s — %w", volName, stderr, err)
}
e.debugf("restoreVolumeData: volume %s created: %s", volName, strings.TrimSpace(string(out)))
// Populate volume from tar
// Populate volume from tar (docker cp streaming)
e.logger.Printf("[INFO] Import: populating volume %s", volName)
e.debugf("restoreVolumeData: populating %s via docker run alpine tar xf...", volName)
popStart := time.Now()
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Minute)
out, err = exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol",
"-v", volDir+":/in:ro",
"alpine", "tar", "xf", "/in/"+volName+".tar", "-C", "/vol").CombinedOutput()
cancel()
if err != nil {
return fmt.Errorf("populating volume %s: %s — %w", volName, strings.TrimSpace(string(out)), err)
if err := e.importVolumeTar(volName, tarPath); err != nil {
return fmt.Errorf("populating volume %s: %w", volName, err)
}
e.debugf("restoreVolumeData: volume %s populated in %v", volName, time.Since(popStart))
}