C6B-F1 cause 1 + §8: additive .fab export (HDD binds AND named volumes)

executeExport no longer either/or gates user data on needs_hdd — a needs_hdd
app bundles BOTH its HDD mounts and its named volumes (sonarr_config = the
whole app DB was silently dropped pre-fix). exportHDDData returns error and
fails LOUDLY on a basename collision between mounts (the manifest keys tars by
basename; the old code silently overwrote the first tar). EstimateExport made
additive to match, so the fits-on-dest gate counts both. Round-trip placement
test proves a userdata tar restores to <HDD_PATH>/userdata through the
untouched import mapping. Red-proofs recorded: either/or revert fails
scenario A; collision-check removal fails the collision test.
This commit is contained in:
2026-07-14 15:18:21 +02:00
parent 8967ba7cb4
commit c6d8bc82a2
4 changed files with 326 additions and 31 deletions
+35 -13
View File
@@ -324,18 +324,24 @@ func (e *Exporter) executeExport(req ExportRequest, job *Job) {
dataDir := filepath.Join(tmpDir, "data")
os.MkdirAll(dataDir, 0755)
// C6B-F1 cause 1 (v0.130.0): user-data capture is ADDITIVE, not either/or. A needs_hdd app
// can hold state in BOTH its HDD binds and its named volumes (sonarr: ${USERDATA_PATH} media
// binds + the sonarr_config volume with the entire app DB) — the old else-branch silently
// dropped every named volume of every needs_hdd app.
if e.provider.GetStackNeedsHDD(req.StackName) {
e.debugf("exporting HDD data for %s", req.StackName)
e.exportHDDData(req.StackName, dataDir, manifest)
e.debugf("HDD data exported: subdirs=%v hasData=%v", manifest.HDDSubdirs, manifest.HasHDDData)
} else {
e.debugf("exporting Docker volumes for %s", req.StackName)
if err := e.exportVolumeData(req.StackName, dataDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("Kötet mentése sikertelen: %v", err))
if err := e.exportHDDData(req.StackName, dataDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("Felhasználói adatok mentése sikertelen: %v", err))
return
}
e.debugf("volume data exported: volumes=%v hasData=%v", manifest.VolumeNames, manifest.HasVolumeData)
e.debugf("HDD data exported: subdirs=%v hasData=%v", manifest.HDDSubdirs, manifest.HasHDDData)
}
e.debugf("exporting Docker volumes for %s", req.StackName)
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))
job.setStep(step, "done", "")
@@ -581,8 +587,11 @@ func (e *Exporter) dumpDatabase(stackName, dbDir string, manifest *Manifest) boo
return true
}
// exportHDDData copies HDD bind mount data for the export.
func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest) {
// exportHDDData copies HDD bind mount data for the export. v0.130.0 (C6B-F1 §8): a basename
// collision between two mounts is a FATAL error (the manifest keys tars by basename; the old
// code silently overwrote the first tar). A non-existent mount is still soft-skipped (honestly
// absent from the manifest — the anti-hollow guard catches total emptiness).
func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest) error {
hddDir := filepath.Join(dataDir, "hdd")
os.MkdirAll(hddDir, 0755)
@@ -590,15 +599,25 @@ func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest)
e.debugf("HDD mounts for %s: %v (%d total)", stackName, mounts, len(mounts))
if len(mounts) == 0 {
e.debugf("no HDD mounts — skipping HDD data export")
return
return nil
}
claimed := make(map[string]string) // subdir → mount that claimed it
for _, mount := range mounts {
if _, err := os.Stat(mount); os.IsNotExist(err) {
e.debugf("HDD mount %s does not exist — skipping", mount)
continue
}
subdir := filepath.Base(mount)
// C6B-F1 §8 (v0.130.0): the manifest keys HDD tars by BASENAME (the import side maps a
// basename back to a path), so two mounts sharing a basename cannot round-trip — the old
// code silently overwrote the first tar with the second (silent partial data loss).
// Renaming can't help either (the import couldn't map the new name), so the only honest
// outcome is a loud failure.
if prev, dup := claimed[subdir]; dup {
return fmt.Errorf("két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket", subdir, prev, mount)
}
claimed[subdir] = mount
tarPath := filepath.Join(hddDir, subdir+".tar")
e.debugf("tarring HDD mount: %s → %s", mount, tarPath)
@@ -616,6 +635,7 @@ func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest)
}
}
manifest.HasHDDData = len(manifest.HDDSubdirs) > 0
return nil
}
// dockerExec is the docker-CLI seam (v0.125.0): runs `docker args...` with optional
@@ -684,9 +704,11 @@ func (e *Exporter) exportVolumeTar(volName, tarPath string) error {
})
}
// 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).
// exportVolumeData exports the app's Docker named volumes. v0.130.0 (C6B-F1): runs for EVERY
// app — needs_hdd apps hold state in named volumes too (sonarr_config = the whole app DB); the
// pre-fix else-branch silently dropped them. 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)