1c47030854
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/
271 lines
8.9 KiB
Go
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")
|
|
}
|
|
}
|