Files
felhom-controller/controller/internal/appexport/roundtrip_test.go
T
admin 73efb091d9
gates / gates (push) Successful in 9s
R-203: the app and its backup look in the same directory — one resolver, every caller
appbackup's path helpers take a NAMESPACE ROOT. Five call sites passed a bare DRIVE path.
On an enrolled drive the two coincide, so nothing showed; on the system-data fallback they
differ by exactly the felhom-data segment, and the app then bound a directory the off-site
capture set never looked at -- while the run reported ok. Measured live on demo-hp: the app
wrote to /mnt/sys_drive/userdata/media/books, the capture set looked for
/mnt/sys_drive/felhom-data/userdata/media/books.

THE RULE NOW HAS ONE EXPRESSION. appbackup.NamespaceRootFor / IsEnrolledDrive encode the
drive-kind comparison; backup.Manager.namespaceRoot and stacks.Manager.inGuest delegate to
it. There were already TWO copies and they differed -- the backup package's compared without
filepath.Clean, the stacks package's with it, so a trailing slash from config would have
flipped the mode in one and not the other.

Sites routed through it:
  - stacks/deploy.go withPathVars -> ${USERDATA_PATH}   (the live defect)
  - appexport/fabplan.go + export.go                     (via a new provider method)
  - web/handlers.go FileBrowser mounts                   (latent: the system drive is
    deliberately never a registered StoragePath, so this is the identity today)

ComputeFabBuckets now receives the namespace root, which is what ComputeCaptureSet has always
received -- so the export's classified paths and the backup's capture set describe the same
directories by construction instead of by coincidence.

Tests are table-driven over BOTH drive kinds, because this survived by being invisible on the
kind that already worked. Red-proofs observed: restoring the bare-path call fails the
system-drive row with the two paths differing by /felhom-data; inverting the drive-kind
comparison fails every enrolled row.
2026-08-04 18:17:05 +02:00

191 lines
6.5 KiB
Go

package appexport
import (
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// v0.124.0 Part 3 (§7D) — the .fab loop's proof at unit level: a real export through
// executeExport produces a bundle that a real executeImport restores to identical content, and
// a corrupted downloaded copy is REFUSED (the .fab's integrity is the gzip CRC + the manifest
// segment validation — there is no per-file checksum; corruption breaks the extract, and the
// import must fail loudly, not restore garbage).
// rtProvider is a filesystem-only fake: a config-only app (no HDD, no volumes, no DB) so the
// whole loop runs without docker.
type rtProvider struct {
stackDir string
stacksDir string
deployed bool
running bool
volumes []string
started bool
stopped int
removed int
savedEnv map[string]string
}
func (p *rtProvider) GetStackDir(string) (string, bool) { return p.stackDir, true }
func (p *rtProvider) GetStackComposePath(string) (string, bool) {
return filepath.Join(p.stackDir, "docker-compose.yml"), true
}
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
func (p *rtProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
// R-203: these fixtures use ENROLLED drive paths, where the namespace root IS the drive path.
// Delegating keeps that identity explicit rather than hardcoding it.
func (p *rtProvider) GetStackNamespaceRoot(name string) string { return p.GetStackHDDPath(name) }
func (p *rtProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
return nil, false
}
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 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 { p.removed++; return nil }
func (p *rtProvider) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
p.savedEnv = env
return nil
}
func waitJob(t *testing.T, e *Exporter) *Job {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
job := e.GetActiveJob()
if job != nil {
job.mu.RLock()
done := job.Done
job.mu.RUnlock()
if done {
return job
}
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("job did not finish in time")
return nil
}
func jobErr(j *Job) string {
j.mu.RLock()
defer j.mu.RUnlock()
if j.Error != "" {
return j.Error
}
for _, s := range j.Steps {
if s.Status == "failed" {
return s.Error
}
}
return ""
}
func TestFabRoundTrip_ExportImportContentEquality(t *testing.T) {
const stack = "rt-app"
lg := log.New(io.Discard, "", 0)
// Source stack: a compose file + a marker config with known content.
srcStack := t.TempDir()
compose := "services:\n rt-app:\n image: alpine\n"
marker := "MARKER-CONTENT-42\n"
os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte(compose), 0644)
os.WriteFile(filepath.Join(srcStack, "settings.conf"), []byte(marker), 0644)
drive := t.TempDir()
prov := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true}
e := NewExporter(prov, lg, "test")
// --- export (the REAL pipeline; same producer as a drive export) ---
if err := e.StartExport(ExportRequest{StackName: stack, DestDrive: drive}); err != nil {
t.Fatalf("StartExport: %v", err)
}
job := waitJob(t, e)
if msg := jobErr(job); msg != "" {
t.Fatalf("export failed: %s", msg)
}
entries, _ := os.ReadDir(ExportDir(drive))
var fabPath string
for _, en := range entries {
if strings.HasSuffix(en.Name(), ".fab") {
fabPath = filepath.Join(ExportDir(drive), en.Name())
}
}
if fabPath == "" {
t.Fatal("no .fab produced")
}
// The manifest is readable and names the app (what /api/export/manifest shows pre-import).
man, err := ReadManifestFromFAB(fabPath)
if err != nil {
t.Fatalf("manifest: %v", err)
}
if man.AppName != stack {
t.Fatalf("manifest app = %q", man.AppName)
}
// --- corrupted copy must be REFUSED (assert the refusal, §10 red-proof of the loop) ---
corrupt := filepath.Join(t.TempDir(), "corrupt.fab")
raw, _ := os.ReadFile(fabPath)
mid := len(raw) / 2
raw[mid] ^= 0xFF
raw[mid+1] ^= 0xFF
os.WriteFile(corrupt, raw, 0644)
prov2 := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false}
e2 := NewExporter(prov2, lg, "test")
if err := e2.StartImport(ImportRequest{FABPath: corrupt}); err != nil {
t.Fatalf("StartImport(corrupt) should start (refusal is async): %v", err)
}
job = waitJob(t, e2)
if msg := jobErr(job); msg == "" {
t.Fatal("a corrupted bundle must FAIL the import (gzip CRC), got success")
}
if prov2.started {
t.Fatal("a refused import must not start the app")
}
if _, err := os.Stat(filepath.Join(prov2.stacksDir, stack, "settings.conf")); !os.IsNotExist(err) {
t.Fatal("a refused import must not restore content")
}
// --- the clean bundle round-trips: restored content is byte-identical ---
prov3 := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false}
e3 := NewExporter(prov3, lg, "test")
if err := e3.StartImport(ImportRequest{FABPath: fabPath}); err != nil {
t.Fatalf("StartImport: %v", err)
}
job = waitJob(t, e3)
if msg := jobErr(job); msg != "" {
t.Fatalf("import failed: %s", msg)
}
restoredStack := filepath.Join(prov3.stacksDir, stack)
for name, want := range map[string]string{
"docker-compose.yml": compose,
"settings.conf": marker,
} {
got, err := os.ReadFile(filepath.Join(restoredStack, name))
if err != nil {
t.Fatalf("restored %s missing: %v", name, err)
}
if string(got) != want {
t.Errorf("restored %s differs:\n got %q\nwant %q", name, got, want)
}
}
if !prov3.started {
t.Error("import must start the restored app")
}
}