package daemon import ( "context" "errors" "slices" "testing" ) type runnerCall struct { sudo bool name string args []string } type runnerStep struct { call runnerCall out []byte err error } type scriptedRunner struct { t *testing.T steps []runnerStep calls []runnerCall } func (r *scriptedRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { return r.next(runnerCall{name: name, args: append([]string(nil), args...)}) } func (r *scriptedRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) { return r.next(runnerCall{sudo: true, args: append([]string(nil), args...)}) } func (r *scriptedRunner) next(call runnerCall) ([]byte, error) { r.t.Helper() r.calls = append(r.calls, call) if len(r.steps) == 0 { r.t.Fatalf("unexpected call: %+v", call) } step := r.steps[0] r.steps = r.steps[1:] if step.call.sudo != call.sudo || step.call.name != call.name || !slices.Equal(step.call.args, call.args) { r.t.Fatalf("call mismatch:\n got: %+v\n want: %+v", call, step.call) } return step.out, step.err } func (r *scriptedRunner) assertExhausted() { r.t.Helper() if len(r.steps) != 0 { r.t.Fatalf("unconsumed steps: %+v", r.steps) } } func sudoStep(out string, err error, args ...string) runnerStep { return runnerStep{ call: runnerCall{sudo: true, args: append([]string(nil), args...)}, out: []byte(out), err: err, } } func TestCreateDMSnapshotFailsWithoutRollbackWhenBaseLoopSetupFails(t *testing.T) { t.Parallel() attachErr := errors.New("attach base loop") runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("", attachErr, "losetup", "-f", "--show", "--read-only", "/rootfs.ext4"), }, } d := &Daemon{runner: runner} wireServices(d) _, err := d.net.createDMSnapshot(context.Background(), "/rootfs.ext4", "/cow.ext4", "fc-rootfs-test") if !errors.Is(err, attachErr) { t.Fatalf("error = %v, want %v", err, attachErr) } runner.assertExhausted() if len(runner.calls) != 1 { t.Fatalf("call count = %d, want 1", len(runner.calls)) } } func TestCreateDMSnapshotRollsBackBaseLoopWhenCowLoopSetupFails(t *testing.T) { t.Parallel() attachErr := errors.New("attach cow loop") runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("/dev/loop10\n", nil, "losetup", "-f", "--show", "--read-only", "/rootfs.ext4"), sudoStep("", attachErr, "losetup", "-f", "--show", "/cow.ext4"), sudoStep("", nil, "losetup", "-d", "/dev/loop10"), }, } d := &Daemon{runner: runner} wireServices(d) _, err := d.net.createDMSnapshot(context.Background(), "/rootfs.ext4", "/cow.ext4", "fc-rootfs-test") if !errors.Is(err, attachErr) { t.Fatalf("error = %v, want %v", err, attachErr) } runner.assertExhausted() } func TestCreateDMSnapshotRollsBackBothLoopsWhenBlockdevFails(t *testing.T) { t.Parallel() blockdevErr := errors.New("read sectors") runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("/dev/loop10\n", nil, "losetup", "-f", "--show", "--read-only", "/rootfs.ext4"), sudoStep("/dev/loop11\n", nil, "losetup", "-f", "--show", "/cow.ext4"), sudoStep("", blockdevErr, "blockdev", "--getsz", "/dev/loop10"), sudoStep("", nil, "losetup", "-d", "/dev/loop11"), sudoStep("", nil, "losetup", "-d", "/dev/loop10"), }, } d := &Daemon{runner: runner} wireServices(d) _, err := d.net.createDMSnapshot(context.Background(), "/rootfs.ext4", "/cow.ext4", "fc-rootfs-test") if !errors.Is(err, blockdevErr) { t.Fatalf("error = %v, want %v", err, blockdevErr) } runner.assertExhausted() } func TestCreateDMSnapshotRollsBackLoopsWhenDMSetupFails(t *testing.T) { t.Parallel() dmErr := errors.New("create dm snapshot") runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("/dev/loop10\n", nil, "losetup", "-f", "--show", "--read-only", "/rootfs.ext4"), sudoStep("/dev/loop11\n", nil, "losetup", "-f", "--show", "/cow.ext4"), sudoStep("12345\n", nil, "blockdev", "--getsz", "/dev/loop10"), sudoStep("", dmErr, "dmsetup", "create", "fc-rootfs-test", "--table", "0 12345 snapshot /dev/loop10 /dev/loop11 P 8"), sudoStep("", nil, "losetup", "-d", "/dev/loop11"), sudoStep("", nil, "losetup", "-d", "/dev/loop10"), }, } d := &Daemon{runner: runner} wireServices(d) _, err := d.net.createDMSnapshot(context.Background(), "/rootfs.ext4", "/cow.ext4", "fc-rootfs-test") if !errors.Is(err, dmErr) { t.Fatalf("error = %v, want %v", err, dmErr) } runner.assertExhausted() for _, call := range runner.calls { if call.sudo && len(call.args) >= 2 && call.args[0] == "dmsetup" && call.args[1] == "remove" { t.Fatalf("unexpected dmsetup remove call: %+v", call) } } } func TestCreateDMSnapshotJoinsRollbackErrors(t *testing.T) { t.Parallel() blockdevErr := errors.New("read sectors") detachErr := errors.New("detach cow loop") runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("/dev/loop10\n", nil, "losetup", "-f", "--show", "--read-only", "/rootfs.ext4"), sudoStep("/dev/loop11\n", nil, "losetup", "-f", "--show", "/cow.ext4"), sudoStep("", blockdevErr, "blockdev", "--getsz", "/dev/loop10"), sudoStep("", detachErr, "losetup", "-d", "/dev/loop11"), sudoStep("", nil, "losetup", "-d", "/dev/loop10"), }, } d := &Daemon{runner: runner} wireServices(d) _, err := d.net.createDMSnapshot(context.Background(), "/rootfs.ext4", "/cow.ext4", "fc-rootfs-test") if err == nil { t.Fatal("expected createDMSnapshot to return an error") } if !errors.Is(err, blockdevErr) || !errors.Is(err, detachErr) { t.Fatalf("error = %v, want joined blockdev and rollback errors", err) } runner.assertExhausted() } func TestCreateDMSnapshotReturnsHandlesOnSuccess(t *testing.T) { t.Parallel() runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("/dev/loop10\n", nil, "losetup", "-f", "--show", "--read-only", "/rootfs.ext4"), sudoStep("/dev/loop11\n", nil, "losetup", "-f", "--show", "/cow.ext4"), sudoStep("12345\n", nil, "blockdev", "--getsz", "/dev/loop10"), sudoStep("", nil, "dmsetup", "create", "fc-rootfs-test", "--table", "0 12345 snapshot /dev/loop10 /dev/loop11 P 8"), }, } d := &Daemon{runner: runner} wireServices(d) handles, err := d.net.createDMSnapshot(context.Background(), "/rootfs.ext4", "/cow.ext4", "fc-rootfs-test") if err != nil { t.Fatalf("createDMSnapshot returned error: %v", err) } want := dmSnapshotHandles{ BaseLoop: "/dev/loop10", COWLoop: "/dev/loop11", DMName: "fc-rootfs-test", DMDev: "/dev/mapper/fc-rootfs-test", } if handles != want { t.Fatalf("handles = %+v, want %+v", handles, want) } runner.assertExhausted() } func TestCleanupDMSnapshotRemovesResourcesInReverseOrder(t *testing.T) { t.Parallel() runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("", nil, "dmsetup", "remove", "fc-rootfs-test"), sudoStep("", nil, "losetup", "-d", "/dev/loop11"), sudoStep("", nil, "losetup", "-d", "/dev/loop10"), }, } d := &Daemon{runner: runner} wireServices(d) err := d.net.cleanupDMSnapshot(context.Background(), dmSnapshotHandles{ BaseLoop: "/dev/loop10", COWLoop: "/dev/loop11", DMName: "fc-rootfs-test", DMDev: "/dev/mapper/fc-rootfs-test", }) if err != nil { t.Fatalf("cleanupDMSnapshot returned error: %v", err) } runner.assertExhausted() } func TestCleanupDMSnapshotUsesPartialHandles(t *testing.T) { t.Parallel() runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("", nil, "dmsetup", "remove", "/dev/mapper/fc-rootfs-test"), sudoStep("", nil, "losetup", "-d", "/dev/loop10"), }, } d := &Daemon{runner: runner} wireServices(d) err := d.net.cleanupDMSnapshot(context.Background(), dmSnapshotHandles{ BaseLoop: "/dev/loop10", DMDev: "/dev/mapper/fc-rootfs-test", }) if err != nil { t.Fatalf("cleanupDMSnapshot returned error: %v", err) } runner.assertExhausted() } func TestCleanupDMSnapshotJoinsTeardownErrors(t *testing.T) { t.Parallel() dmErr := errors.New("remove dm") cowErr := errors.New("detach cow") baseErr := errors.New("detach base") runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("", dmErr, "dmsetup", "remove", "fc-rootfs-test"), sudoStep("", cowErr, "losetup", "-d", "/dev/loop11"), sudoStep("", baseErr, "losetup", "-d", "/dev/loop10"), }, } d := &Daemon{runner: runner} wireServices(d) err := d.net.cleanupDMSnapshot(context.Background(), dmSnapshotHandles{ BaseLoop: "/dev/loop10", COWLoop: "/dev/loop11", DMName: "fc-rootfs-test", }) if err == nil { t.Fatal("expected cleanupDMSnapshot to return an error") } for _, expected := range []error{dmErr, cowErr, baseErr} { if !errors.Is(err, expected) { t.Fatalf("cleanup error %q not joined into %v", expected, err) } } runner.assertExhausted() } func TestRemoveDMSnapshotRetriesBusyDevice(t *testing.T) { t.Parallel() busyErr := errors.New("exit status 1: device-mapper: remove ioctl on fc-rootfs-test failed: Device or resource busy") runner := &scriptedRunner{ t: t, steps: []runnerStep{ sudoStep("", busyErr, "dmsetup", "remove", "fc-rootfs-test"), sudoStep("", busyErr, "dmsetup", "remove", "fc-rootfs-test"), sudoStep("", nil, "dmsetup", "remove", "fc-rootfs-test"), }, } d := &Daemon{runner: runner} wireServices(d) if err := d.net.removeDMSnapshot(context.Background(), "fc-rootfs-test"); err != nil { t.Fatalf("removeDMSnapshot returned error: %v", err) } runner.assertExhausted() }