banger/internal/daemon/dmsnap/dmsnap_test.go
Thales Maciel 8bfa525568
test: cover imagemgr + dmsnap helpers
Both packages had zero tests before this change. The helpers in them
are pure (imagemgr) or scripted-runner-friendly (dmsnap), so they're
cheap to pin and worth catching regressions on.

imagemgr/paths_test.go:
  * DebianBasePackages returns a defensive copy (mutating the result
    can't poison subsequent calls — important because hashPackages
    digests this list).
  * BuildMetadataPackages stays in lockstep with DebianBasePackages.
  * hashPackages is order-sensitive and includes a trailing newline
    in its canonical join (regression guard for any future "sort the
    list before hashing" temptation that would invalidate every
    on-disk hash).
  * StageOptionalArtifactPath returns "" for empty/whitespace input
    and joins by name otherwise.
  * WritePackagesMetadata writes <rootfs>.packages.sha256 with the
    expected hash, no-ops on empty rootfs path or empty package list.
  * DebianBasePackages contains the small critical-package floor
    (ca-certificates, curl, git) so a future apt-list trim can't
    silently drop them.

dmsnap/dmsnap_test.go:
  * Create runs losetup base, losetup cow, blockdev getsz, dmsetup
    create in that order, with a snapshot table referencing the loops
    in (base, cow) order — a swap would corrupt every VM.
  * Create's failure path unwinds with losetup -d on cow then base.
  * Cleanup tears down dmsetup before losetup (otherwise dmsetup sees
    EBUSY against vanished backing devices).
  * Cleanup falls back to DMDev when DMName is empty.
  * Cleanup tolerates "No such device" on losetup -d (idempotent
    re-run after a partial cleanup).
  * Cleanup surfaces non-missing losetup errors (the tolerance is
    narrow on purpose).
  * Remove returns nil on a missing target and surfaces non-retryable
    errors immediately.

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

288 lines
9.7 KiB
Go

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