package daemon import ( "context" "os" "path/filepath" "strings" "testing" "banger/internal/model" ) func TestHandlesFileRoundtrip(t *testing.T) { t.Parallel() dir := t.TempDir() want := model.VMHandles{ PID: 4242, TapDevice: "tap-fc-abcd", BaseLoop: "/dev/loop9", COWLoop: "/dev/loop10", DMName: "fc-rootfs-abcd", DMDev: "/dev/mapper/fc-rootfs-abcd", } if err := writeHandlesFile(dir, want); err != nil { t.Fatalf("writeHandlesFile: %v", err) } got, present, err := readHandlesFile(dir) if err != nil { t.Fatalf("readHandlesFile: %v", err) } if !present { t.Fatal("readHandlesFile reported no file after write") } if got != want { t.Fatalf("roundtrip mismatch:\n got %+v\n want %+v", got, want) } } func TestSetVMHandlesMirrorsRuntimeTeardownState(t *testing.T) { t.Parallel() d := &Daemon{} wireServices(d) vmDir := t.TempDir() vm := testVM("mirror", "image-mirror", "172.16.0.77") vm.Runtime.VMDir = vmDir want := model.VMHandles{ TapDevice: "tap-fc-0077", BaseLoop: "/dev/loop17", COWLoop: "/dev/loop18", DMName: "fc-rootfs-0077", DMDev: "/dev/mapper/fc-rootfs-0077", } d.vm.setVMHandles(&vm, want) if vm.Runtime.TapDevice != want.TapDevice || vm.Runtime.BaseLoop != want.BaseLoop || vm.Runtime.COWLoop != want.COWLoop || vm.Runtime.DMName != want.DMName || vm.Runtime.DMDev != want.DMDev { t.Fatalf("runtime teardown state not mirrored: got %+v want %+v", vm.Runtime, want) } } func TestHandlesFileMissingReturnsZero(t *testing.T) { t.Parallel() h, present, err := readHandlesFile(t.TempDir()) if err != nil { t.Fatalf("readHandlesFile (missing): %v", err) } if present { t.Fatal("present = true for missing file") } if !h.IsZero() { t.Fatalf("expected zero-value handles, got %+v", h) } } func TestHandlesFileCorruptReturnsError(t *testing.T) { t.Parallel() dir := t.TempDir() if err := os.WriteFile(handlesFilePath(dir), []byte("{not json"), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } if _, _, err := readHandlesFile(dir); err == nil { t.Fatal("expected parse error for corrupt file") } } func TestHandleCacheConcurrent(t *testing.T) { t.Parallel() c := newHandleCache() done := make(chan struct{}) // One writer, multiple readers — prove the RWMutex usage. go func() { for i := 0; i < 1000; i++ { c.set("vm-1", model.VMHandles{PID: i}) } close(done) }() for i := 0; i < 1000; i++ { _, _ = c.get("vm-1") } <-done c.clear("vm-1") if _, ok := c.get("vm-1"); ok { t.Fatal("cache entry still present after clear") } } // TestRediscoverHandlesLoadsScratchWhenProcessDead proves the stale- // cleanup path: the firecracker process is gone, but the scratch // file tells us which kernel resources the previous daemon still // owes us a teardown on. func TestRediscoverHandlesLoadsScratchWhenProcessDead(t *testing.T) { t.Parallel() vmDir := t.TempDir() apiSock := filepath.Join(t.TempDir(), "fc.sock") stale := model.VMHandles{ PID: 999999, BaseLoop: "/dev/loop99", COWLoop: "/dev/loop100", DMName: "fc-rootfs-gone", DMDev: "/dev/mapper/fc-rootfs-gone", } if err := writeHandlesFile(vmDir, stale); err != nil { t.Fatalf("writeHandlesFile: %v", err) } // A scripted runner that reports "no such process" when reconcile // probes via pgrep. runner := &scriptedRunner{ t: t, steps: []runnerStep{ {call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, err: &exitErr{code: 1}}, }, } d := &Daemon{runner: runner} wireServices(d) vm := testVM("gone", "image-gone", "172.16.0.250") vm.Runtime.APISockPath = apiSock vm.Runtime.VMDir = vmDir got, alive, err := d.vm.rediscoverHandles(context.Background(), vm) if err != nil { t.Fatalf("rediscoverHandles: %v", err) } if alive { t.Fatal("alive = true, want false (process dead)") } // Even when dead, the scratch handles must be returned so // cleanupRuntime can tear DM + loops + tap down. if got.DMName != stale.DMName || got.BaseLoop != stale.BaseLoop || got.COWLoop != stale.COWLoop { t.Fatalf("stale handles lost: got %+v, want fields from %+v", got, stale) } runner.assertExhausted() } // TestRediscoverHandlesPrefersLivePIDOverScratch: scratch file has an // old PID, but pgrep finds the actual current PID via the api sock. func TestRediscoverHandlesPrefersLivePIDOverScratch(t *testing.T) { t.Parallel() vmDir := t.TempDir() apiSock := filepath.Join(t.TempDir(), "fc.sock") if err := writeHandlesFile(vmDir, model.VMHandles{PID: 111, DMName: "dm-x"}); err != nil { t.Fatalf("writeHandlesFile: %v", err) } runner := &scriptedRunner{ t: t, steps: []runnerStep{ {call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, out: []byte("222\n")}, }, } d := &Daemon{runner: runner} wireServices(d) vm := testVM("moved", "image-moved", "172.16.0.251") vm.Runtime.APISockPath = apiSock vm.Runtime.VMDir = vmDir got, alive, err := d.vm.rediscoverHandles(context.Background(), vm) if err != nil { t.Fatalf("rediscoverHandles: %v", err) } if !alive { t.Fatal("alive = false, want true (pgrep found a PID)") } if got.PID != 222 { t.Fatalf("PID = %d, want 222 (from pgrep, not scratch)", got.PID) } if got.DMName != "dm-x" { t.Fatalf("scratch fields dropped: %+v", got) } runner.assertExhausted() } // TestClearVMHandlesRemovesScratchFile proves the cleanup contract. func TestClearVMHandlesRemovesScratchFile(t *testing.T) { t.Parallel() vmDir := t.TempDir() if err := writeHandlesFile(vmDir, model.VMHandles{PID: 42}); err != nil { t.Fatalf("writeHandlesFile: %v", err) } d := &Daemon{} wireServices(d) vm := testVM("sweep", "image-sweep", "172.16.0.252") vm.Runtime.VMDir = vmDir d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: 42}) d.vm.clearVMHandles(vm) if _, err := os.Stat(handlesFilePath(vmDir)); !os.IsNotExist(err) { t.Fatalf("scratch file still present: %v", err) } if h, ok := d.vm.handles.get(vm.ID); ok && !h.IsZero() { t.Fatalf("cache entry survives clear: %+v", h) } } // exitErr is a minimal stand-in for an exec-style non-zero exit. // Used by scripted runners to simulate "pgrep found nothing". type exitErr struct{ code int } func (e *exitErr) Error() string { return "exit status " + strings.Repeat("1", 1) }