banger/internal/daemon/snapshot_test.go
Thales Maciel 16702bd5e1
daemon split (6/n): extract wireServices + drop lazy service getters
Factor the service + capability wiring out of Daemon.Open() into
wireServices(d), an idempotent helper that constructs HostNetwork,
ImageService, WorkspaceService, and VMService from whatever
infrastructure (runner, store, config, layout, logger, closing) is
already set on d. Open() calls it once after filling the composition
root; tests that build &Daemon{...} literals call it to get a working
service graph, preinstalling stubs on the fields they want to fake.

Drops the four lazy-init getters on *Daemon — d.hostNet(),
d.imageSvc(), d.workspaceSvc(), d.vmSvc() — whose sole purpose was
keeping test literals working. Every production call site now reads
d.net / d.img / d.ws / d.vm directly; the services are guaranteed
non-nil once Open returns. No behavior change.

Mechanical: all existing `d.xxxSvc()` calls (production + tests)
rewritten to field access; each `d := &Daemon{...}` in tests gets a
trailing wireServices(d) so the literal + wiring are side-by-side.
Tests that override a pre-built service (e.g. d.img = &ImageService{
bundleFetch: stub}) now set the override before wireServices so the
replacement propagates into VMService's peer pointer.

Also nil-guards HostNetwork.stopVMDNS and d.store in Close() so
partially-initialised daemons (pre-reconcile open failure) still
tear down cleanly — same contract the old lazy getters provided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:55:28 -03:00

324 lines
9.3 KiB
Go

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()
}