Files
tiny-forge/internal/volsnap/restore_test.go
T
alexei.dolgolyov 1c47030854 feat(volsnap): volume snapshot restore (backlog #6)
Restore a captured volume snapshot onto an image workload's live host-bind
data volumes, then redeploy — the most destructive workload action, built to
the adversarially-reviewed design (C1–C6) with all data-loss guards.

- Engine.Restore (engine-owned): all-or-nothing pre-flight re-resolution from
  the workload's CURRENT config (never the tamperable manifest), per-filesystem
  disk pre-check, per-workload lock, container quiesce, extract-to-tmp, durable
  pre-restore snapshot, write-ahead journal, atomic rename swap, redeploy, and
  crash-recovery sweep (RecoverInterruptedRestores) wired before serving.
- internal/keyedmutex: shared per-key lock; deployer now serializes every
  deploy entrypoint per workload via DispatchPlugin (+ LockWorkload/RedeployLocked
  for the restore re-dispatch, no deadlock).
- Untrusted-archive extractor: zip-slip containment, type allow-list (reg/dir
  only), decompression-bomb cap, manifest-index bounds.
- POST /api/workloads/{id}/snapshots/{sid}/restore: admin, X-Confirm-Restore
  header (CSRF), per-workload single-flight (409).
- WebUI: Restore button + danger ConfirmDialog + busy state + i18n (en/ru).

Scope: image-source only; scopes absolute/stage/project (driven off the same
supportedScopes constant capture uses).

Plan-reviewed before coding; per-phase go/security/ts reviews; final review
READY TO MERGE. Security review caught + fixed a CRITICAL manifest-Source path
traversal (re-derive target from current config + base containment).

Plan: plans/volume-snapshot-restore/
2026-06-22 17:23:52 +03:00

271 lines
8.9 KiB
Go

package volsnap
import (
"os"
"path/filepath"
"testing"
"github.com/alexei/tinyforge/internal/store"
)
func mkDirWith(t *testing.T, dir, fname, content string) {
t.Helper()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, fname), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func readIn(t *testing.T, dir, fname string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, fname))
if err != nil {
t.Fatalf("read %s/%s: %v", dir, fname, err)
}
return string(b)
}
func TestSwapVolumeDir_ReplacesAndPreservesOld(t *testing.T) {
root := t.TempDir()
live := filepath.Join(root, "data")
tmp := filepath.Join(root, ".data.tmp")
old := filepath.Join(root, ".data.old")
mkDirWith(t, live, "f.txt", "ORIGINAL")
mkDirWith(t, tmp, "f.txt", "RESTORED")
hadOld, err := swapVolumeDir(live, tmp, old)
if err != nil {
t.Fatalf("swap: %v", err)
}
if !hadOld {
t.Error("hadOld should be true when a live dir existed")
}
if got := readIn(t, live, "f.txt"); got != "RESTORED" {
t.Errorf("live = %q, want RESTORED", got)
}
if got := readIn(t, old, "f.txt"); got != "ORIGINAL" {
t.Errorf("old = %q, want ORIGINAL (prior live preserved)", got)
}
}
func TestSwapVolumeDir_MissingLive(t *testing.T) {
root := t.TempDir()
live := filepath.Join(root, "data") // does not exist
tmp := filepath.Join(root, ".data.tmp")
old := filepath.Join(root, ".data.old")
mkDirWith(t, tmp, "f.txt", "RESTORED")
hadOld, err := swapVolumeDir(live, tmp, old)
if err != nil {
t.Fatalf("swap: %v", err)
}
if hadOld {
t.Error("hadOld should be false when no live dir existed")
}
if got := readIn(t, live, "f.txt"); got != "RESTORED" {
t.Errorf("live = %q, want RESTORED", got)
}
}
func TestSwapVolumeDir_RevertsOnSecondRenameFailure(t *testing.T) {
// The data-loss-critical path: the live→old rename succeeds, then tmp→live
// fails (here: tmp is absent). swapVolumeDir MUST self-revert old→live so
// the live dir is never left missing, and old must not be left dangling.
root := t.TempDir()
live := filepath.Join(root, "data")
old := filepath.Join(root, ".data.old")
mkDirWith(t, live, "f.txt", "ORIGINAL")
hadOld, err := swapVolumeDir(live, filepath.Join(root, ".data.tmp" /* absent */), old)
if err == nil {
t.Fatal("expected swap to fail when tmp is absent")
}
if got := readIn(t, live, "f.txt"); got != "ORIGINAL" {
t.Errorf("live = %q, want ORIGINAL restored by self-revert", got)
}
if _, statErr := os.Stat(old); !os.IsNotExist(statErr) {
t.Errorf("old dir should have been renamed back to live, not left dangling")
}
_ = hadOld
}
func TestStagingDirs_SameParentAsLive(t *testing.T) {
// R2 invariant: tmp and old must be siblings of the live dir's parent so
// every rename in the swap is intra-filesystem (atomic).
live := filepath.FromSlash("/srv/data/postgres")
tmp, old := stagingDirs(live, "tok", 3)
wantParent := filepath.Dir(live)
if filepath.Dir(tmp) != wantParent || filepath.Dir(old) != wantParent {
t.Errorf("staging dirs not siblings of live's parent: tmp=%s old=%s parent=%s", tmp, old, wantParent)
}
if tmp == old {
t.Error("tmp and old must be distinct paths")
}
}
func TestRollbackSwaps_RestoresOriginals(t *testing.T) {
root := t.TempDir()
var done []swap
for _, name := range []string{"vol0", "vol1"} {
live := filepath.Join(root, name)
tmp := filepath.Join(root, "."+name+".tmp")
old := filepath.Join(root, "."+name+".old")
mkDirWith(t, live, "f.txt", "ORIGINAL-"+name)
mkDirWith(t, tmp, "f.txt", "RESTORED-"+name)
hadOld, err := swapVolumeDir(live, tmp, old)
if err != nil {
t.Fatalf("swap %s: %v", name, err)
}
done = append(done, swap{live: live, old: old, tmp: tmp, hadOld: hadOld})
}
// Both are now RESTORED; rolling back must return both to ORIGINAL.
rollbackSwaps(done)
for _, name := range []string{"vol0", "vol1"} {
if got := readIn(t, filepath.Join(root, name), "f.txt"); got != "ORIGINAL-"+name {
t.Errorf("%s after rollback = %q, want ORIGINAL-%s", name, got, name)
}
}
}
func TestRollbackSwaps_PartialLeavesUnswappedIntact(t *testing.T) {
root := t.TempDir()
// vol0 swaps successfully; vol1 "fails" (its tmp is absent) so only vol0 is
// recorded in done. Rollback of vol0 must restore the original.
live0 := filepath.Join(root, "vol0")
tmp0 := filepath.Join(root, ".vol0.tmp")
old0 := filepath.Join(root, ".vol0.old")
mkDirWith(t, live0, "f.txt", "ORIGINAL-0")
mkDirWith(t, tmp0, "f.txt", "RESTORED-0")
hadOld, err := swapVolumeDir(live0, tmp0, old0)
if err != nil {
t.Fatalf("swap vol0: %v", err)
}
live1 := filepath.Join(root, "vol1")
mkDirWith(t, live1, "f.txt", "ORIGINAL-1")
if _, err := swapVolumeDir(live1, filepath.Join(root, ".vol1.tmp" /* absent */), filepath.Join(root, ".vol1.old")); err == nil {
t.Fatal("expected vol1 swap to fail (tmp absent)")
}
rollbackSwaps([]swap{{live: live0, old: old0, tmp: tmp0, hadOld: hadOld}})
if got := readIn(t, live0, "f.txt"); got != "ORIGINAL-0" {
t.Errorf("vol0 after rollback = %q, want ORIGINAL-0", got)
}
// vol1 was never swapped; its original must be untouched.
if got := readIn(t, live1, "f.txt"); got != "ORIGINAL-1" {
t.Errorf("vol1 = %q, want ORIGINAL-1 (never swapped)", got)
}
}
func TestPreflightResolve_AllOrNothing(t *testing.T) {
eng, st, base := newRestoreEngine(t)
_ = eng
w, err := st.CreateWorkload(store.Workload{
Name: "app", Kind: "project", SourceKind: "image",
SourceConfig: `{"image":"x","port":80,"volumes":[` +
`{"source":"data","target":"/data","scope":"project"},` +
`{"source":"var","target":"/var","scope":"stage"}]}`,
})
if err != nil {
t.Fatalf("create workload: %v", err)
}
settings, _ := st.GetSettings()
// A snapshotted target no longer declared by the workload ⇒ whole abort (C3).
if _, err := preflightResolve(st, w, settings, []SnapshotVolume{
{Index: 0, Target: "/data", Scope: "project", Source: "data"},
{Index: 1, Target: "/gone", Scope: "project", Source: "gone"},
}); err == nil {
t.Fatal("expected all-or-nothing abort when a target is no longer declared")
}
// All declared ⇒ resolves every volume under BaseVolumePath.
resolved, err := preflightResolve(st, w, settings, []SnapshotVolume{
{Index: 0, Target: "/data", Scope: "project", Source: "data"},
{Index: 1, Target: "/var", Scope: "stage", Source: "var"},
})
if err != nil {
t.Fatalf("preflight: %v", err)
}
if len(resolved) != 2 {
t.Fatalf("resolved %d volumes, want 2", len(resolved))
}
for _, rv := range resolved {
if ok, _ := pathWithinBase(base, rv.LivePath); !ok {
t.Errorf("volume %q resolved to %q, outside base %q", rv.Target, rv.LivePath, base)
}
}
}
// TestPreflightResolve_IgnoresManifestSource is the regression guard for the
// security fix: a tampered manifest whose Source tries to escape (../../etc)
// must NOT redirect the swap target — the host path is re-derived from the
// workload's CURRENT trusted config (Source "data"), staying under the base.
func TestPreflightResolve_IgnoresManifestSource(t *testing.T) {
_, st, base := newRestoreEngine(t)
w, err := st.CreateWorkload(store.Workload{
Name: "app", Kind: "project", SourceKind: "image",
SourceConfig: `{"image":"x","port":80,"volumes":[{"source":"data","target":"/data","scope":"project"}]}`,
})
if err != nil {
t.Fatalf("create workload: %v", err)
}
settings, _ := st.GetSettings()
resolved, err := preflightResolve(st, w, settings, []SnapshotVolume{
{Index: 0, Target: "/data", Scope: "project", Source: "../../../../etc"},
})
if err != nil {
t.Fatalf("preflight: %v", err)
}
if len(resolved) != 1 {
t.Fatalf("resolved %d, want 1", len(resolved))
}
if ok, _ := pathWithinBase(base, resolved[0].LivePath); !ok {
t.Errorf("manifest Source escaped containment: resolved %q outside base %q",
resolved[0].LivePath, base)
}
if filepath.Base(resolved[0].LivePath) != "data" {
t.Errorf("expected target derived from current config (data), got %q", resolved[0].LivePath)
}
}
func TestArchiveUncompressedSize(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "a.txt"), "hello") // 5
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o700); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world!") // 6
dest := filepath.Join(t.TempDir(), "snap.tar.gz")
if _, err := writeArchive(dest, []VolumeRef{{Target: "/d", Scope: "project", Source: "d", HostPath: root}}); err != nil {
t.Fatal(err)
}
per, total, err := archiveUncompressedSize(dest, maxRestoreUncompressedBytes)
if err != nil {
t.Fatalf("size: %v", err)
}
if total != 11 {
t.Errorf("total = %d, want 11", total)
}
if per[0] != 11 {
t.Errorf("perIndex[0] = %d, want 11", per[0])
}
if _, _, err := archiveUncompressedSize(dest, 4); err == nil {
t.Error("expected sizing to abort past the decompression cap")
}
}
func TestFreeDiskBytes(t *testing.T) {
n, err := freeDiskBytes(t.TempDir())
if err != nil {
t.Fatalf("freeDiskBytes: %v", err)
}
if n == 0 {
t.Error("expected non-zero free space on the temp filesystem")
}
}