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:
@@ -2,6 +2,7 @@ package appexport
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
@@ -324,7 +325,10 @@ func (e *Exporter) executeExport(req ExportRequest, job *Job) {
|
||||
e.debugf("HDD data exported: subdirs=%v hasData=%v", manifest.HDDSubdirs, manifest.HasHDDData)
|
||||
} else {
|
||||
e.debugf("exporting Docker volumes for %s", req.StackName)
|
||||
e.exportVolumeData(req.StackName, dataDir, manifest)
|
||||
if err := e.exportVolumeData(req.StackName, dataDir, manifest); err != nil {
|
||||
e.failJob(job, step, fmt.Sprintf("Kötet mentése sikertelen: %v", err))
|
||||
return
|
||||
}
|
||||
e.debugf("volume data exported: volumes=%v hasData=%v", manifest.VolumeNames, manifest.HasVolumeData)
|
||||
}
|
||||
e.debugf("step 3 (user data) done in %v", time.Since(stepStart))
|
||||
@@ -336,6 +340,13 @@ func (e *Exporter) executeExport(req ExportRequest, job *Job) {
|
||||
job.setStep(step, "running", "")
|
||||
stepStart = time.Now()
|
||||
|
||||
// v0.125.0 fail-loud guard (scenario B): never package a bundle whose manifest claims data
|
||||
// that is not actually in the staging tree.
|
||||
if err := assertBundleDataComplete(tmpDir, manifest); err != nil {
|
||||
e.failJob(job, step, fmt.Sprintf("A csomag hiányos lenne — az export leállt: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate total size
|
||||
manifest.TotalSizeBytes = calcDirSize(tmpDir)
|
||||
e.debugf("total bundle content size: %s (%d bytes)", humanizeBytes(manifest.TotalSizeBytes), manifest.TotalSizeBytes)
|
||||
@@ -583,14 +594,17 @@ func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest)
|
||||
continue
|
||||
}
|
||||
subdir := filepath.Base(mount)
|
||||
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
|
||||
|
||||
tarPath := filepath.Join(hddDir, subdir+".tar")
|
||||
e.debugf("tarring HDD mount: %s → %s", mount, tarPath)
|
||||
tarStart := time.Now()
|
||||
if err := tarDirectory(mount, tarPath); err != nil {
|
||||
e.logger.Printf("[WARN] Export: failed to tar %s: %v", mount, err)
|
||||
// v0.125.0: claim the subdir ONLY on success — a claimed-but-absent tar would trip
|
||||
// the packaging assertion; an honestly-skipped mount stays out of the manifest.
|
||||
e.logger.Printf("[WARN] Export: failed to tar %s (excluded from the bundle): %v", mount, err)
|
||||
os.Remove(tarPath)
|
||||
} else {
|
||||
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
|
||||
if info, _ := os.Stat(tarPath); info != nil {
|
||||
e.debugf("HDD tar complete: %s (%s) in %v", subdir, humanizeBytes(info.Size()), time.Since(tarStart))
|
||||
}
|
||||
@@ -599,8 +613,76 @@ func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest)
|
||||
manifest.HasHDDData = len(manifest.HDDSubdirs) > 0
|
||||
}
|
||||
|
||||
// exportVolumeData exports Docker named volumes for apps without HDD storage.
|
||||
func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifest) {
|
||||
// dockerExec is the docker-CLI seam (v0.125.0): runs `docker args...` with optional
|
||||
// stdin/stdout STREAMING and returns captured stderr (truncated). The volume legs stream tars
|
||||
// over the docker API (docker cp) — NEVER via `docker run -v <controller-path>` host mounts,
|
||||
// which the daemon resolves against the GUEST filesystem and silently strands the tar when the
|
||||
// controller itself runs containerized (the v0.124.0 HIGH finding). Package var so unit tests
|
||||
// inject a recorder (no docker on test boxes).
|
||||
var dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stdout = stdout
|
||||
var errBuf bytes.Buffer
|
||||
cmd.Stderr = &errBuf
|
||||
err := cmd.Run()
|
||||
stderr := strings.TrimSpace(errBuf.String())
|
||||
if len(stderr) > 500 {
|
||||
stderr = stderr[:500] + "..."
|
||||
}
|
||||
return stderr, err
|
||||
}
|
||||
|
||||
// withVolumeHelper creates a stopped helper container pinning volName at /vol, runs fn(cid),
|
||||
// and ALWAYS force-removes the helper — including on fn failure (no leaked alpine containers).
|
||||
func (e *Exporter) withVolumeHelper(volName string, fn func(cid string) error) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
var cidBuf bytes.Buffer
|
||||
stderr, err := dockerExec(ctx, nil, &cidBuf, "create", "-v", volName+":/vol", "alpine", "true")
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating helper container for volume %s: %s — %w", volName, stderr, err)
|
||||
}
|
||||
cid := strings.TrimSpace(cidBuf.String())
|
||||
defer func() {
|
||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer rmCancel()
|
||||
if _, rmErr := dockerExec(rmCtx, nil, nil, "rm", "-f", cid); rmErr != nil {
|
||||
e.logger.Printf("[WARN] appexport: helper container %s cleanup failed: %v", cid, rmErr)
|
||||
}
|
||||
}()
|
||||
return fn(cid)
|
||||
}
|
||||
|
||||
// exportVolumeTar streams one volume's content into tarPath via `docker cp <cid>:/vol/. -`
|
||||
// (tar on stdout — zero shared paths; live-probed 2026-07-13: content, subdirs, symlinks,
|
||||
// empty files and uid/gid all round-trip).
|
||||
func (e *Exporter) exportVolumeTar(volName, tarPath string) error {
|
||||
return e.withVolumeHelper(volName, func(cid string) error {
|
||||
f, err := os.Create(tarPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating %s: %w", tarPath, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
stderr, err := dockerExec(ctx, nil, f, "cp", cid+":/vol/.", "-")
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
os.Remove(tarPath)
|
||||
return fmt.Errorf("streaming volume %s: %s — %w", volName, stderr, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(tarPath)
|
||||
return fmt.Errorf("flushing %s: %w", tarPath, closeErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// exportVolumeData exports Docker named volumes for apps without HDD storage. v0.125.0: a
|
||||
// failed volume export is FATAL (export must never report success on a hollow bundle — the
|
||||
// pre-fix WARN+continue is exactly how the data-loss bundles were born).
|
||||
func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifest) error {
|
||||
volDir := filepath.Join(dataDir, "volumes")
|
||||
os.MkdirAll(volDir, 0755)
|
||||
|
||||
@@ -608,28 +690,15 @@ func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifes
|
||||
e.debugf("Docker volumes for %s: %v (%d total)", stackName, volumes, len(volumes))
|
||||
if len(volumes) == 0 {
|
||||
e.debugf("no Docker volumes — skipping volume data export")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, volName := range volumes {
|
||||
tarPath := filepath.Join(volDir, volName+".tar")
|
||||
e.debugf("exporting volume %s via docker run alpine tar...", volName)
|
||||
e.debugf("exporting volume %s via docker cp streaming...", volName)
|
||||
volStart := time.Now()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
|
||||
"-v", volName+":/vol:ro",
|
||||
"-v", volDir+":/out",
|
||||
"alpine", "tar", "cf", "/out/"+volName+".tar", "-C", "/vol", ".")
|
||||
out, err := cmd.CombinedOutput()
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
e.logger.Printf("[WARN] Export: volume %s export failed: %s — %v",
|
||||
volName, strings.TrimSpace(string(out)), err)
|
||||
e.debugf("volume %s export failed: %s", volName, strings.TrimSpace(string(out)))
|
||||
os.Remove(tarPath)
|
||||
continue
|
||||
if err := e.exportVolumeTar(volName, tarPath); err != nil {
|
||||
return fmt.Errorf("volume %s export failed: %w", volName, err)
|
||||
}
|
||||
if info, _ := os.Stat(tarPath); info != nil {
|
||||
e.debugf("volume %s exported: %s in %v", volName, humanizeBytes(info.Size()), time.Since(volStart))
|
||||
@@ -637,6 +706,27 @@ func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifes
|
||||
manifest.VolumeNames = append(manifest.VolumeNames, volName)
|
||||
}
|
||||
manifest.HasVolumeData = len(manifest.VolumeNames) > 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertBundleDataComplete is the fail-loud post-export guard (v0.125.0, scenario B): every
|
||||
// manifest-CLAIMED data tar must exist non-empty in the staging tree before packaging. A
|
||||
// mismatch aborts the export — yesterday's outcome ("success" with a hollow bundle) is the
|
||||
// one this exists to make impossible.
|
||||
func assertBundleDataComplete(tmpDir string, manifest *Manifest) error {
|
||||
for _, v := range manifest.VolumeNames {
|
||||
fi, err := os.Stat(filepath.Join(tmpDir, "data", "volumes", v+".tar"))
|
||||
if err != nil || fi.Size() == 0 {
|
||||
return fmt.Errorf("bundle assertion: volume %q is claimed by the manifest but its tar is missing or empty", v)
|
||||
}
|
||||
}
|
||||
for _, s := range manifest.HDDSubdirs {
|
||||
fi, err := os.Stat(filepath.Join(tmpDir, "data", "hdd", s+".tar"))
|
||||
if err != nil || fi.Size() == 0 {
|
||||
return fmt.Errorf("bundle assertion: HDD subdir %q is claimed by the manifest but its tar is missing or empty", s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTarGz creates a gzipped tar archive of a directory.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ type rtProvider struct {
|
||||
stackDir string
|
||||
stacksDir string
|
||||
deployed bool
|
||||
running bool
|
||||
volumes []string
|
||||
started bool
|
||||
stopped int
|
||||
removed int
|
||||
savedEnv map[string]string
|
||||
}
|
||||
|
||||
@@ -32,17 +36,17 @@ func (p *rtProvider) GetStackComposePath(string) (string, bool) {
|
||||
}
|
||||
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
|
||||
func (p *rtProvider) IsStackRunning(string) bool { return false }
|
||||
func (p *rtProvider) StopStack(string) error { return nil }
|
||||
func (p *rtProvider) IsStackRunning(string) bool { return p.running }
|
||||
func (p *rtProvider) StopStack(string) error { p.stopped++; return nil }
|
||||
func (p *rtProvider) StartStack(string) error { p.started = true; return nil }
|
||||
func (p *rtProvider) GetStackDisplayName(n string) string { return "RT " + n }
|
||||
func (p *rtProvider) GetStackNeedsHDD(string) bool { return false }
|
||||
func (p *rtProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *rtProvider) GetDockerVolumes(string) []string { return p.volumes }
|
||||
func (p *rtProvider) IsStackDeployed(string) bool { return p.deployed }
|
||||
func (p *rtProvider) GetDecryptedEnv(string) map[string]string { return nil }
|
||||
func (p *rtProvider) GetStacksBaseDir() string { return p.stacksDir }
|
||||
func (p *rtProvider) RefreshStacks() error { return nil }
|
||||
func (p *rtProvider) RemoveStackVolumes(string) error { return nil }
|
||||
func (p *rtProvider) RemoveStackVolumes(string) error { p.removed++; return nil }
|
||||
func (p *rtProvider) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
|
||||
p.savedEnv = env
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package appexport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.125.0 — the containerized-.fab volume-strand fix (IA finding 1, HIGH). These tests pin:
|
||||
// (B) export can no longer lie — a stranded/empty tar aborts the export, no bundle;
|
||||
// (C) import validates BEFORE it destroys — a hollow bundle is refused with the app untouched;
|
||||
// the docker-cp command construction (image, mount shape, cp direction) and the always-remove
|
||||
// helper-container rule. The real docker legs are covered by the §3 live probe + §13 round-trip.
|
||||
|
||||
// dockerCall records one dockerExec invocation.
|
||||
type dockerCall struct {
|
||||
args []string
|
||||
stdin bool
|
||||
}
|
||||
|
||||
// swapDockerExec installs a scripted fake for the package seam and restores it on cleanup.
|
||||
func swapDockerExec(t *testing.T, fn func(call dockerCall, stdin io.Reader, stdout io.Writer) (string, error)) *[]dockerCall {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
calls := &[]dockerCall{}
|
||||
orig := dockerExec
|
||||
dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
|
||||
mu.Lock()
|
||||
c := dockerCall{args: append([]string{}, args...), stdin: stdin != nil}
|
||||
*calls = append(*calls, c)
|
||||
mu.Unlock()
|
||||
return fn(c, stdin, stdout)
|
||||
}
|
||||
t.Cleanup(func() { dockerExec = orig })
|
||||
return calls
|
||||
}
|
||||
|
||||
func volTestExporter(t *testing.T, volumes []string) (*Exporter, *rtProvider, string) {
|
||||
t.Helper()
|
||||
srcStack := t.TempDir()
|
||||
os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n vol-app:\n image: alpine\n"), 0644)
|
||||
prov := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true, volumes: volumes}
|
||||
drive := t.TempDir()
|
||||
return NewExporter(prov, log.New(io.Discard, "", 0), "test"), prov, drive
|
||||
}
|
||||
|
||||
// Scenario B: a volume tar that fails to materialize (cp "succeeds" but writes nothing — the
|
||||
// strand's signature) must FAIL the export with the volume named, and NO bundle may exist.
|
||||
// Red-proof: remove the assertBundleDataComplete call → this test fails (hollow success).
|
||||
func TestExport_HollowVolumeTarAbortsExport(t *testing.T) {
|
||||
swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
|
||||
switch c.args[0] {
|
||||
case "create":
|
||||
fmt.Fprint(stdout, "cid-123\n")
|
||||
return "", nil
|
||||
case "cp":
|
||||
return "", nil // writes NOTHING to stdout — the stranded-tar signature
|
||||
case "rm":
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected docker call: %v", c.args)
|
||||
})
|
||||
|
||||
e, _, drive := volTestExporter(t, []string{"vol1"})
|
||||
if err := e.StartExport(ExportRequest{StackName: "vol-app", DestDrive: drive}); err != nil {
|
||||
t.Fatalf("StartExport: %v", err)
|
||||
}
|
||||
job := waitJob(t, e)
|
||||
msg := jobErr(job)
|
||||
if msg == "" {
|
||||
t.Fatal("a hollow volume tar must FAIL the export — got success")
|
||||
}
|
||||
if !strings.Contains(msg, "vol1") {
|
||||
t.Errorf("the error must NAME the missing volume, got %q", msg)
|
||||
}
|
||||
entries, _ := os.ReadDir(ExportDir(drive))
|
||||
for _, en := range entries {
|
||||
if strings.HasSuffix(en.Name(), ".fab") {
|
||||
t.Fatalf("a bundle was produced despite the hollow tar: %s", en.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C: a bundle whose manifest claims a volume without its tar is refused BEFORE any
|
||||
// destructive step — the app is not stopped, no volume is removed or recreated, zero docker
|
||||
// calls happen. Red-proof: disable the pre-flight (pre-fix order: wipe first, discover later)
|
||||
// → the zero-destruction assertions fail.
|
||||
func TestImport_HollowBundleRefusedBeforeDestroy(t *testing.T) {
|
||||
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
|
||||
return "", nil
|
||||
})
|
||||
|
||||
// Handcraft a hollow bundle: manifest CLAIMS volume data, data/volumes is empty.
|
||||
tree := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(tree, "config"), 0755)
|
||||
os.MkdirAll(filepath.Join(tree, "data", "volumes"), 0755)
|
||||
os.WriteFile(filepath.Join(tree, "config", "docker-compose.yml"), []byte("services: {}\n"), 0644)
|
||||
man := &Manifest{
|
||||
Version: ManifestVersion, AppName: "vol-app", DisplayName: "Vol App",
|
||||
HasVolumeData: true, VolumeNames: []string{"vol1"},
|
||||
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(), "hollow.fab")
|
||||
if err := createTarGz(fab, tree); err != nil {
|
||||
t.Fatalf("createTarGz: %v", err)
|
||||
}
|
||||
|
||||
prov := &rtProvider{stackDir: t.TempDir(), stacksDir: t.TempDir(), deployed: true, running: true}
|
||||
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
||||
if err := e.StartImport(ImportRequest{FABPath: fab}); err != nil {
|
||||
t.Fatalf("StartImport: %v", err)
|
||||
}
|
||||
job := waitJob(t, e)
|
||||
msg := jobErr(job)
|
||||
if msg == "" {
|
||||
t.Fatal("a hollow bundle must be REFUSED — got success")
|
||||
}
|
||||
if !strings.Contains(msg, "érintetlen") {
|
||||
t.Errorf("refusal copy must state the app is untouched, got %q", msg)
|
||||
}
|
||||
// THE exact non-effects: nothing was stopped, wiped, recreated or started.
|
||||
if prov.stopped != 0 || prov.removed != 0 {
|
||||
t.Fatalf("refusal happened AFTER destruction: stopped=%d removedVolumes=%d", prov.stopped, prov.removed)
|
||||
}
|
||||
if prov.started {
|
||||
t.Fatal("a refused import must not start the app")
|
||||
}
|
||||
if len(*calls) != 0 {
|
||||
t.Fatalf("a refused import must make ZERO docker calls, got %v", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Command construction + helper hygiene: the export leg uses create/cp/rm with the exact arg
|
||||
// shapes the §3 probe validated, and the helper container is force-removed EVEN when cp fails.
|
||||
func TestExportVolumeTar_CommandShapesAndHelperCleanup(t *testing.T) {
|
||||
t.Run("happy path shapes", func(t *testing.T) {
|
||||
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
|
||||
switch c.args[0] {
|
||||
case "create":
|
||||
fmt.Fprint(stdout, "cid-abc\n")
|
||||
case "cp":
|
||||
fmt.Fprint(stdout, "TARBYTES")
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
e, _, _ := volTestExporter(t, nil)
|
||||
tarPath := filepath.Join(t.TempDir(), "v.tar")
|
||||
if err := e.exportVolumeTar("vol1", tarPath); err != nil {
|
||||
t.Fatalf("exportVolumeTar: %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(tarPath)
|
||||
if string(got) != "TARBYTES" {
|
||||
t.Fatalf("tar content = %q", got)
|
||||
}
|
||||
want := [][]string{
|
||||
{"create", "-v", "vol1:/vol", "alpine", "true"},
|
||||
{"cp", "cid-abc:/vol/.", "-"},
|
||||
{"rm", "-f", "cid-abc"},
|
||||
}
|
||||
assertCalls(t, *calls, want)
|
||||
})
|
||||
|
||||
t.Run("helper removed on cp failure", func(t *testing.T) {
|
||||
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
|
||||
switch c.args[0] {
|
||||
case "create":
|
||||
fmt.Fprint(stdout, "cid-err\n")
|
||||
return "", nil
|
||||
case "cp":
|
||||
return "boom", fmt.Errorf("cp failed")
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
e, _, _ := volTestExporter(t, nil)
|
||||
tarPath := filepath.Join(t.TempDir(), "v.tar")
|
||||
if err := e.exportVolumeTar("vol1", tarPath); err == nil {
|
||||
t.Fatal("cp failure must surface")
|
||||
}
|
||||
if _, err := os.Stat(tarPath); !os.IsNotExist(err) {
|
||||
t.Error("a failed export must not leave a partial tar")
|
||||
}
|
||||
last := (*calls)[len(*calls)-1]
|
||||
if strings.Join(last.args, " ") != "rm -f cid-err" {
|
||||
t.Fatalf("helper container must be force-removed on failure, last call: %v", last.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("import leg shapes", func(t *testing.T) {
|
||||
calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
|
||||
if c.args[0] == "create" {
|
||||
fmt.Fprint(stdout, "cid-imp\n")
|
||||
}
|
||||
if c.args[0] == "cp" && !c.stdin {
|
||||
t.Error("import cp must stream the tar on stdin")
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
e, _, _ := volTestExporter(t, nil)
|
||||
tarPath := filepath.Join(t.TempDir(), "v.tar")
|
||||
os.WriteFile(tarPath, []byte("TAR"), 0644)
|
||||
if err := e.importVolumeTar("vol1", tarPath); err != nil {
|
||||
t.Fatalf("importVolumeTar: %v", err)
|
||||
}
|
||||
want := [][]string{
|
||||
{"create", "-v", "vol1:/vol", "alpine", "true"},
|
||||
{"cp", "-", "cid-imp:/vol"},
|
||||
{"rm", "-f", "cid-imp"},
|
||||
}
|
||||
assertCalls(t, *calls, want)
|
||||
})
|
||||
}
|
||||
|
||||
func assertCalls(t *testing.T, got []dockerCall, want [][]string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("docker calls = %d, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if strings.Join(got[i].args, " ") != strings.Join(want[i], " ") {
|
||||
t.Errorf("call %d = %v, want %v", i, got[i].args, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""docker-run volume-path gate (v0.125.0, scenario D) — the class behind the v0.124.0 HIGH
|
||||
finding: a `docker … -v <path>` mount whose host side is a CONTROLLER-LOCAL path (os.MkdirTemp
|
||||
etc.) resolves against the GUEST filesystem when the controller runs containerized, silently
|
||||
stranding data. Every `"-v"` argument in non-test Go code must be on the explicit allowlist
|
||||
below; anything new fails the gate until it is reviewed and either rewritten (docker cp
|
||||
streaming — appexport's pattern) or proven host-visible and allowlisted WITH ITS WHY.
|
||||
|
||||
Run from controller/: python scripts/docker_run_volume_path_gate.py
|
||||
"""
|
||||
import io, os, re, sys
|
||||
|
||||
ROOTS = ["internal", "cmd"]
|
||||
|
||||
# (file suffix, substring that must appear on the "-v" line, why it is safe)
|
||||
ALLOWLIST = [
|
||||
("internal/appbackup/dbdump.go", '"psql", "-v"',
|
||||
"psql's own -v flag (ON_ERROR_STOP) — not a docker mount at all"),
|
||||
("internal/appexport/export.go", '"create", "-v", volName+":/vol"',
|
||||
"named-volume mount (no host path): docker resolves volume names daemon-side; the tar "
|
||||
"itself streams via docker cp (v0.125.0)"),
|
||||
("internal/backup/backup.go", '"-v", volName+":/vol:ro"',
|
||||
"Tier-1 volume dump, named-volume source — daemon-side, no host path"),
|
||||
("internal/backup/backup.go", '"-v", dumpDir+":/out"',
|
||||
"Tier-1 volume dump target: dumpDir is ALWAYS a registered-drive namespace path "
|
||||
"(/mnt/** or /opt/docker/** — the golden deployment bind-mounts these into the "
|
||||
"controller container at IDENTICAL paths, so the daemon resolves them correctly; "
|
||||
"verified by container-inspect 2026-07-13)"),
|
||||
("internal/backup/restore.go", '"-v", volName+":/vol"',
|
||||
"Tier-1 volume restore, named-volume dest — daemon-side"),
|
||||
("internal/backup/restore.go", '"-v", dumpDir+":/in:ro"',
|
||||
"Tier-1 volume restore source: same registered-drive namespace argument as the dump "
|
||||
"target above — host-visible by the identical binds"),
|
||||
("internal/web/handlers.go", '"compose", "down", "-v"',
|
||||
"docker compose's own --volumes flag (DR reset wipes the stack's volumes) — not a mount"),
|
||||
]
|
||||
|
||||
VLINE = re.compile(r'"-v"')
|
||||
|
||||
|
||||
def allowed(path, line):
|
||||
p = path.replace("\\", "/")
|
||||
for suffix, marker, _why in ALLOWLIST:
|
||||
if p.endswith(suffix) and marker in line:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
hits = 0
|
||||
for root in ROOTS:
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
for fn in files:
|
||||
if not fn.endswith(".go") or fn.endswith("_test.go"):
|
||||
continue
|
||||
path = os.path.join(dirpath, fn)
|
||||
for lineno, line in enumerate(io.open(path, encoding="utf-8"), 1):
|
||||
if VLINE.search(line) and not allowed(path, line):
|
||||
hits += 1
|
||||
print("%s:%d %s" % (path, lineno,
|
||||
line.strip()[:100].encode("ascii", "backslashreplace").decode()))
|
||||
if hits:
|
||||
print("DOCKER -v GATE FAILED: %d unreviewed '-v' argument(s) — rewrite as docker cp "
|
||||
"streaming or allowlist with a WHY" % hits)
|
||||
sys.exit(1)
|
||||
print("docker -v gate OK — every volume mount is named-volume or proven host-visible")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user