package dmsnap import ( "context" "errors" "strings" "testing" ) // scriptedRunner records every RunSudo call's argv and plays back a // scripted sequence of (out, err) responses. Going past the script is // a fatal error so an unexpected extra call shows up clearly. Mirrors // the pattern used by internal/daemon/fcproc/fcproc_test.go but stays // local to dmsnap (this is a leaf package). type scriptedRunner struct { t *testing.T scripts []scriptedReply calls [][]string } type scriptedReply struct { out []byte err error } func (r *scriptedRunner) RunSudo(_ context.Context, args ...string) ([]byte, error) { r.t.Helper() r.calls = append(r.calls, append([]string(nil), args...)) if len(r.scripts) == 0 { r.t.Fatalf("unexpected RunSudo call %d: %v", len(r.calls), args) } step := r.scripts[0] r.scripts = r.scripts[1:] return step.out, step.err } func argsContain(args []string, want ...string) bool { if len(args) < len(want) { return false } for i, w := range want { if args[i] != w { return false } } return true } // TestCreateOrdersOpsAndPopulatesHandles pins the four-step setup // sequence Create runs in: losetup base (read-only), losetup cow, // blockdev getsz, dmsetup create with a snapshot table. If the order // drifts the helper would build dm targets backed by the wrong // device, which silently corrupts every VM that uses the snapshot. func TestCreateOrdersOpsAndPopulatesHandles(t *testing.T) { runner := &scriptedRunner{ t: t, scripts: []scriptedReply{ {out: []byte("/dev/loop0\n")}, // losetup -f --show --read-only rootfs {out: []byte("/dev/loop1\n")}, // losetup -f --show cow {out: []byte("16384\n")}, // blockdev --getsz /dev/loop0 {}, // dmsetup create }, } handles, err := Create(context.Background(), runner, "/state/rootfs.ext4", "/state/cow.img", "fc-rootfs-test") if err != nil { t.Fatalf("Create: %v", err) } if len(runner.calls) != 4 { t.Fatalf("got %d RunSudo calls, want 4", len(runner.calls)) } if !argsContain(runner.calls[0], "losetup", "-f", "--show", "--read-only", "/state/rootfs.ext4") { t.Fatalf("call 0 = %v, want read-only losetup of rootfs", runner.calls[0]) } if !argsContain(runner.calls[1], "losetup", "-f", "--show", "/state/cow.img") { t.Fatalf("call 1 = %v, want losetup of cow", runner.calls[1]) } if !argsContain(runner.calls[2], "blockdev", "--getsz", "/dev/loop0") { t.Fatalf("call 2 = %v, want blockdev getsz on base loop", runner.calls[2]) } if !argsContain(runner.calls[3], "dmsetup", "create", "fc-rootfs-test") { t.Fatalf("call 3 = %v, want dmsetup create of dm name", runner.calls[3]) } // The snapshot table must reference the base + cow loops in that // order. Pin it so a future refactor can't accidentally swap them // (which would make the COW the read-only side and corrupt every // write). tableArg := runner.calls[3][len(runner.calls[3])-1] if !strings.Contains(tableArg, "snapshot /dev/loop0 /dev/loop1") { t.Fatalf("dmsetup table = %q, want 'snapshot /dev/loop0 /dev/loop1'", tableArg) } if handles.BaseLoop != "/dev/loop0" || handles.COWLoop != "/dev/loop1" { t.Fatalf("loops = %+v, want base=loop0 cow=loop1", handles) } if handles.DMName != "fc-rootfs-test" || handles.DMDev != "/dev/mapper/fc-rootfs-test" { t.Fatalf("dm names = %+v, want fc-rootfs-test", handles) } } // TestCreateFailureRunsCleanup verifies that a partial setup is // unwound on failure: if dmsetup create fails after both loops are // attached, Create must release them via losetup -d before returning. // Without this the host accumulates orphan loop devices on every // failed VM start. func TestCreateFailureRunsCleanup(t *testing.T) { dmCreateErr := errors.New("dmsetup table refused") runner := &scriptedRunner{ t: t, scripts: []scriptedReply{ {out: []byte("/dev/loop0\n")}, // losetup base {out: []byte("/dev/loop1\n")}, // losetup cow {out: []byte("16384\n")}, // blockdev getsz {err: dmCreateErr}, // dmsetup create fails {}, // cleanup: losetup -d /dev/loop1 {}, // cleanup: losetup -d /dev/loop0 }, } _, err := Create(context.Background(), runner, "/state/rootfs.ext4", "/state/cow.img", "fc-rootfs-test") if !errors.Is(err, dmCreateErr) { t.Fatalf("Create error = %v, want dmsetup error to bubble", err) } if len(runner.calls) != 6 { t.Fatalf("got %d RunSudo calls, want 6 (4 setup + 2 cleanup)", len(runner.calls)) } // Cleanup order: cow first, then base, mirroring stack unwind. if !argsContain(runner.calls[4], "losetup", "-d", "/dev/loop1") { t.Fatalf("call 4 = %v, want losetup -d on cow loop", runner.calls[4]) } if !argsContain(runner.calls[5], "losetup", "-d", "/dev/loop0") { t.Fatalf("call 5 = %v, want losetup -d on base loop", runner.calls[5]) } } // TestCleanupOrdersDmsetupBeforeLosetup pins the destruction order: // the dm target must come down BEFORE the loops it sits on are // detached, otherwise dmsetup remove sees EBUSY because the target's // backing devices vanished mid-flight. func TestCleanupOrdersDmsetupBeforeLosetup(t *testing.T) { runner := &scriptedRunner{ t: t, scripts: []scriptedReply{ {}, // dmsetup remove fc-rootfs-test {}, // losetup -d cow {}, // losetup -d base }, } handles := Handles{ BaseLoop: "/dev/loop0", COWLoop: "/dev/loop1", DMName: "fc-rootfs-test", DMDev: "/dev/mapper/fc-rootfs-test", } if err := Cleanup(context.Background(), runner, handles); err != nil { t.Fatalf("Cleanup: %v", err) } if len(runner.calls) != 3 { t.Fatalf("got %d RunSudo calls, want 3", len(runner.calls)) } if !argsContain(runner.calls[0], "dmsetup", "remove", "fc-rootfs-test") { t.Fatalf("call 0 = %v, want dmsetup remove first", runner.calls[0]) } if !argsContain(runner.calls[1], "losetup", "-d", "/dev/loop1") { t.Fatalf("call 1 = %v, want cow loop detach second", runner.calls[1]) } if !argsContain(runner.calls[2], "losetup", "-d", "/dev/loop0") { t.Fatalf("call 2 = %v, want base loop detach last", runner.calls[2]) } } // TestCleanupFallsBackToDMDevWhenNameEmpty covers the "we only know // the /dev/mapper path" branch — Remove accepts either form, and // Cleanup picks DMDev when DMName isn't recorded (older state files // only stored the path). func TestCleanupFallsBackToDMDevWhenNameEmpty(t *testing.T) { runner := &scriptedRunner{ t: t, scripts: []scriptedReply{ {}, // dmsetup remove /dev/mapper/fc-rootfs-test {}, // losetup -d cow {}, // losetup -d base }, } handles := Handles{ BaseLoop: "/dev/loop0", COWLoop: "/dev/loop1", DMDev: "/dev/mapper/fc-rootfs-test", // DMName intentionally empty. } if err := Cleanup(context.Background(), runner, handles); err != nil { t.Fatalf("Cleanup: %v", err) } if !argsContain(runner.calls[0], "dmsetup", "remove", "/dev/mapper/fc-rootfs-test") { t.Fatalf("call 0 = %v, want dmsetup remove of DMDev path", runner.calls[0]) } } // TestCleanupTolerantOfMissingLoops pins the idempotency contract: // running cleanup against handles whose loops are already detached // (e.g. a daemon crash mid-cleanup, then a second pass) returns nil // rather than failing. dmsnap.isMissing recognises kernel/losetup's // "No such device" wording. func TestCleanupTolerantOfMissingLoops(t *testing.T) { missing := errors.New("losetup: /dev/loop1: No such device or address") runner := &scriptedRunner{ t: t, scripts: []scriptedReply{ {}, // dmsetup remove ok {err: missing}, // losetup -d cow: already gone {err: missing}, // losetup -d base: already gone }, } handles := Handles{ BaseLoop: "/dev/loop0", COWLoop: "/dev/loop1", DMName: "fc-rootfs-test", } if err := Cleanup(context.Background(), runner, handles); err != nil { t.Fatalf("Cleanup: %v, want nil for already-gone loops", err) } } // TestCleanupSurfacesUnexpectedLoopErrors confirms that NON-missing // errors do bubble up — the idempotency guard is narrow on purpose, // so an EBUSY or permission error from losetup actually fails the // cleanup. func TestCleanupSurfacesUnexpectedLoopErrors(t *testing.T) { wedged := errors.New("losetup: /dev/loop1: device is busy") runner := &scriptedRunner{ t: t, scripts: []scriptedReply{ {}, {err: wedged}, {}, }, } handles := Handles{ BaseLoop: "/dev/loop0", COWLoop: "/dev/loop1", DMName: "fc-rootfs-test", } err := Cleanup(context.Background(), runner, handles) if !errors.Is(err, wedged) { t.Fatalf("Cleanup error = %v, want busy error to bubble", err) } } // TestRemoveReturnsNilOnMissingTarget mirrors the loop-cleanup // idempotency guard: an absent dm target is the desired end state, so // Remove returns nil without retrying. func TestRemoveReturnsNilOnMissingTarget(t *testing.T) { missing := errors.New("dmsetup: target not found") runner := &scriptedRunner{ t: t, scripts: []scriptedReply{ {err: missing}, }, } if err := Remove(context.Background(), runner, "fc-rootfs-test"); err != nil { t.Fatalf("Remove: %v, want nil for missing target", err) } if len(runner.calls) != 1 { t.Fatalf("got %d RunSudo calls, want 1 (missing should not retry)", len(runner.calls)) } } // TestRemoveBubblesNonRetryableErrors covers the third Remove branch: // errors that aren't busy and aren't missing must surface immediately // so the daemon can record the failure and clean up by other means. func TestRemoveBubblesNonRetryableErrors(t *testing.T) { denied := errors.New("dmsetup: permission denied") runner := &scriptedRunner{ t: t, scripts: []scriptedReply{ {err: denied}, }, } err := Remove(context.Background(), runner, "fc-rootfs-test") if !errors.Is(err, denied) { t.Fatalf("Remove error = %v, want permission error to bubble", err) } if len(runner.calls) != 1 { t.Fatalf("got %d RunSudo calls, want 1 (permission error should not retry)", len(runner.calls)) } }