2958946517
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
232 lines
9.0 KiB
Go
232 lines
9.0 KiB
Go
package backup
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeRecoveryProvider is a configurable StackDataProvider for the capture + restore tests.
|
|
//
|
|
// It records the ORDER of every mutating call (R-47): the local restore path's correctness is an
|
|
// ordering property — definition persisted, then the DB service alone, then the replay, then the
|
|
// full start — and an ordering guarantee nothing observes is one refactor from silently reverting to
|
|
// the shape that produced H4.
|
|
type fakeRecoveryProvider struct {
|
|
info RecoveryInfo
|
|
hdd string
|
|
secrets map[string]string // returned by RecoverStackSecrets
|
|
gotEnv map[string]string // captured by RecreateStackDefinitionFromUnit
|
|
running bool // returned by RefreshAndIsRunning
|
|
stopped bool
|
|
|
|
calls []string // ordered log: stop / recreate / startsvc:<a,b> / start
|
|
gotServices []string // services passed to StartStackServices
|
|
startSvcErr error // injected StartStackServices failure
|
|
fullStarted bool // a FULL StartStack happened
|
|
}
|
|
|
|
func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
|
|
return filepath.Join(f.info.StackDir, "docker-compose.yml"), true
|
|
}
|
|
func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { return nil }
|
|
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
|
|
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
|
|
func (f *fakeRecoveryProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
|
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
|
|
func (f *fakeRecoveryProvider) StopStack(string) error {
|
|
f.stopped = true
|
|
f.calls = append(f.calls, "stop")
|
|
return nil
|
|
}
|
|
func (f *fakeRecoveryProvider) StartStack(string) error {
|
|
f.fullStarted = true
|
|
f.calls = append(f.calls, "start")
|
|
return nil
|
|
}
|
|
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running }
|
|
func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
|
return f.info, true
|
|
}
|
|
func (f *fakeRecoveryProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) {
|
|
return nil, false
|
|
}
|
|
func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string {
|
|
return f.secrets
|
|
}
|
|
func (f *fakeRecoveryProvider) RecreateStackDefinitionFromUnit(_, _ string, fullEnv map[string]string) error {
|
|
f.gotEnv = fullEnv
|
|
f.calls = append(f.calls, "recreate")
|
|
return nil
|
|
}
|
|
func (f *fakeRecoveryProvider) StartStackServices(_ string, services []string) error {
|
|
f.gotServices = append([]string(nil), services...)
|
|
f.calls = append(f.calls, "startsvc:"+strings.Join(services, ","))
|
|
return f.startSvcErr
|
|
}
|
|
|
|
// TestCaptureRecoveryUnitIsSecretFree proves the captured unit (a) contains compose+config+manifest,
|
|
// (b) enumerates the existing dumps, and (c) is SECRET-FREE: a secret value present in the SOURCE
|
|
// app.yaml does NOT appear anywhere in the unit, because the capture writes the stripped NonSecretEnv
|
|
// (not the raw app.yaml). The manifest records the secret NAMES + data_key flag for recovery-from-guest.
|
|
func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
|
|
const secretVal = "SUPERSECRETVALUE-do-not-leak"
|
|
tmp := t.TempDir()
|
|
stackDir := filepath.Join(tmp, "stack")
|
|
drive := filepath.Join(tmp, "drive") // in-guest namespace root (basename need not be felhom-data)
|
|
if err := os.MkdirAll(stackDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Source stack files — the raw app.yaml DELIBERATELY holds a secret to prove it's not copied.
|
|
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"),
|
|
"services:\n app:\n image: example/app:1.2.3\n")
|
|
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example\n")
|
|
mustWrite(t, filepath.Join(stackDir, "app.yaml"),
|
|
"deployed: true\nenv:\n DB_PASSWORD: "+secretVal+"\n SUBDOMAIN: example\n")
|
|
|
|
// Pre-existing dumps (written by the dump flow before capture).
|
|
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "example"), "example-postgres.sql"), "dump")
|
|
mustWrite(t, filepath.Join(AppVolumeDumpPath(drive, "example"), "example_data.tar"), "tar")
|
|
|
|
// RecoveryInfo as the adapter would build it: secret values already stripped from NonSecretEnv.
|
|
info := RecoveryInfo{
|
|
StackDir: stackDir,
|
|
DisplayName: "Example",
|
|
ImagePins: []string{"example/app:1.2.3"},
|
|
NonSecretEnv: map[string]string{"SUBDOMAIN": "example", "HDD_PATH": drive},
|
|
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
|
|
DataKeyEnvVars: []string{"SECRET_KEY"},
|
|
}
|
|
m := &Manager{
|
|
logger: log.New(io.Discard, "", 0),
|
|
systemDataPath: filepath.Join(tmp, "system"), // != drive ⇒ drive treated as in-guest, nsRoot = drive
|
|
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive},
|
|
version: "vtest",
|
|
}
|
|
|
|
if err := m.CaptureRecoveryUnit("example"); err != nil {
|
|
t.Fatalf("capture: %v", err)
|
|
}
|
|
|
|
composeDir := RecoveryUnitComposePath(drive, "example")
|
|
for _, f := range []string{"docker-compose.yml", ".felhom.yml", "app.yaml"} {
|
|
if _, err := os.Stat(filepath.Join(composeDir, f)); err != nil {
|
|
t.Errorf("missing captured config %s: %v", f, err)
|
|
}
|
|
}
|
|
|
|
// Manifest structure.
|
|
mfData, err := os.ReadFile(RecoveryUnitManifestPath(drive, "example"))
|
|
if err != nil {
|
|
t.Fatalf("manifest: %v", err)
|
|
}
|
|
var man RecoveryManifest
|
|
if err := json.Unmarshal(mfData, &man); err != nil {
|
|
t.Fatalf("manifest parse: %v", err)
|
|
}
|
|
if man.AppName != "example" || man.ControllerVer != "vtest" {
|
|
t.Errorf("manifest meta: app=%q ver=%q", man.AppName, man.ControllerVer)
|
|
}
|
|
if len(man.ImagePins) != 1 || man.ImagePins[0] != "example/app:1.2.3" {
|
|
t.Errorf("image pins: %v", man.ImagePins)
|
|
}
|
|
if len(man.SecretEnvVars) != 2 {
|
|
t.Errorf("secret env-var names: %v (want 2)", man.SecretEnvVars)
|
|
}
|
|
if len(man.DataKeyEnvVars) != 1 || man.DataKeyEnvVars[0] != "SECRET_KEY" {
|
|
t.Errorf("data-key env-vars: %v", man.DataKeyEnvVars)
|
|
}
|
|
if len(man.DBDumps) != 1 || len(man.VolumeDumps) != 1 {
|
|
t.Errorf("dumps enumerated: db=%v vol=%v", man.DBDumps, man.VolumeDumps)
|
|
}
|
|
|
|
// app.yaml in the unit must carry the non-secret env but NOT the secret value.
|
|
appy := mustRead(t, filepath.Join(composeDir, "app.yaml"))
|
|
if !strings.Contains(appy, "SUBDOMAIN") {
|
|
t.Errorf("stripped app.yaml missing non-secret env: %s", appy)
|
|
}
|
|
|
|
// SECRET-FREE invariant: the secret value must not appear ANYWHERE in the unit.
|
|
unitRoot := RecoveryUnitPath(drive, "example")
|
|
_ = filepath.WalkDir(unitRoot, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
if strings.Contains(mustRead(t, path), secretVal) {
|
|
t.Errorf("SECRET LEAK: %q found in %s", secretVal, path)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// TestCaptureRecoveryUnitIdempotent proves the checksum-skip guard: a second capture with unchanged
|
|
// config does NOT rewrite the manifest (CreatedAt stable), but a config change DOES.
|
|
func TestCaptureRecoveryUnitIdempotent(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
stackDir := filepath.Join(tmp, "stack")
|
|
drive := filepath.Join(tmp, "drive")
|
|
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: ex/app:1\n")
|
|
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "ex"), "ex.sql"), "d")
|
|
|
|
info := RecoveryInfo{StackDir: stackDir, DisplayName: "Ex", ImagePins: []string{"ex/app:1"},
|
|
NonSecretEnv: map[string]string{"SUBDOMAIN": "ex"}}
|
|
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(tmp, "sys"),
|
|
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive}, version: "v1"}
|
|
|
|
manifestPath := RecoveryUnitManifestPath(drive, "ex")
|
|
if err := m.CaptureRecoveryUnit("ex"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := readManifest(manifestPath)
|
|
if first == nil {
|
|
t.Fatal("manifest not written")
|
|
}
|
|
|
|
// Second capture, unchanged → skipped (manifest byte-identical incl. CreatedAt).
|
|
if err := m.CaptureRecoveryUnit("ex"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again := readManifest(manifestPath); again.CreatedAt != first.CreatedAt {
|
|
t.Errorf("idempotent capture rewrote manifest: %q -> %q", first.CreatedAt, again.CreatedAt)
|
|
}
|
|
|
|
// Change the compose → must rewrite (config checksum differs).
|
|
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: ex/app:2\n")
|
|
m.stackProvider.(*fakeRecoveryProvider).info.ImagePins = []string{"ex/app:2"}
|
|
if err := m.CaptureRecoveryUnit("ex"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed := readManifest(manifestPath)
|
|
if len(changed.ImagePins) != 1 || changed.ImagePins[0] != "ex/app:2" {
|
|
t.Errorf("config change not captured: %v", changed.ImagePins)
|
|
}
|
|
if changed.Checksums["docker-compose.yml"] == first.Checksums["docker-compose.yml"] {
|
|
t.Errorf("compose checksum should change after edit")
|
|
}
|
|
}
|
|
|
|
func mustWrite(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func mustRead(t *testing.T, path string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|