package daemon import ( "bufio" "context" "encoding/json" "net" "os" "path/filepath" "strings" "testing" "time" "banger/internal/api" "banger/internal/model" "banger/internal/rpc" "banger/internal/store" ) func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) { dir := t.TempDir() rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir) db := openDefaultImageStore(t, dir) d := &Daemon{ config: model.DaemonConfig{ DefaultImageName: "default", DefaultRootfs: rootfs, DefaultKernel: kernel, }, store: db, } if err := d.ensureDefaultImage(context.Background()); err != nil { t.Fatalf("ensureDefaultImage: %v", err) } image, err := db.GetImageByName(context.Background(), "default") if err != nil { t.Fatalf("GetImageByName: %v", err) } if image.RootfsPath != rootfs { t.Fatalf("RootfsPath = %q, want %q", image.RootfsPath, rootfs) } if image.KernelPath != kernel { t.Fatalf("KernelPath = %q, want %q", image.KernelPath, kernel) } if image.Managed { t.Fatal("default image should be unmanaged") } } func TestEnsureDefaultImageLeavesCurrentUnmanagedDefaultUntouched(t *testing.T) { dir := t.TempDir() rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) db := openDefaultImageStore(t, dir) now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) image := model.Image{ ID: "default-id", Name: "default", Managed: false, RootfsPath: rootfs, KernelPath: kernel, InitrdPath: initrd, ModulesDir: modulesDir, PackagesPath: packages, Docker: true, CreatedAt: now, UpdatedAt: now, } if err := db.UpsertImage(context.Background(), image); err != nil { t.Fatalf("UpsertImage: %v", err) } d := &Daemon{ config: model.DaemonConfig{ DefaultImageName: "default", DefaultRootfs: rootfs, DefaultKernel: kernel, DefaultInitrd: initrd, DefaultModulesDir: modulesDir, DefaultPackagesFile: packages, }, store: db, } if err := d.ensureDefaultImage(context.Background()); err != nil { t.Fatalf("ensureDefaultImage: %v", err) } got, err := db.GetImageByName(context.Background(), "default") if err != nil { t.Fatalf("GetImageByName: %v", err) } if got.ID != image.ID { t.Fatalf("ID = %q, want %q", got.ID, image.ID) } if !got.UpdatedAt.Equal(image.UpdatedAt) { t.Fatalf("UpdatedAt = %s, want unchanged %s", got.UpdatedAt, image.UpdatedAt) } } func TestEnsureDefaultImageReconcilesStaleUnmanagedDefaultInPlace(t *testing.T) { dir := t.TempDir() rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) db := openDefaultImageStore(t, dir) now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) stale := model.Image{ ID: "default-id", Name: "default", Managed: false, RootfsPath: "/home/thales/projects/personal/banger/rootfs-docker.ext4", KernelPath: "/home/thales/projects/personal/banger/wtf/root/boot/vmlinux-6.8.0-94-generic", InitrdPath: "/home/thales/projects/personal/banger/wtf/root/boot/initrd.img-6.8.0-94-generic", ModulesDir: "/home/thales/projects/personal/banger/wtf/root/lib/modules/6.8.0-94-generic", PackagesPath: "/home/thales/projects/personal/banger/packages.apt", Docker: true, CreatedAt: now, UpdatedAt: now, } if err := db.UpsertImage(context.Background(), stale); err != nil { t.Fatalf("UpsertImage: %v", err) } vm := testVM("uses-default", stale.ID, "172.16.0.25") if err := db.UpsertVM(context.Background(), vm); err != nil { t.Fatalf("UpsertVM: %v", err) } d := &Daemon{ config: model.DaemonConfig{ DefaultImageName: "default", DefaultRootfs: rootfs, DefaultKernel: kernel, DefaultInitrd: initrd, DefaultModulesDir: modulesDir, DefaultPackagesFile: packages, }, store: db, } if err := d.ensureDefaultImage(context.Background()); err != nil { t.Fatalf("ensureDefaultImage: %v", err) } got, err := db.GetImageByName(context.Background(), "default") if err != nil { t.Fatalf("GetImageByName: %v", err) } if got.ID != stale.ID { t.Fatalf("ID = %q, want preserved %q", got.ID, stale.ID) } if !got.CreatedAt.Equal(stale.CreatedAt) { t.Fatalf("CreatedAt = %s, want preserved %s", got.CreatedAt, stale.CreatedAt) } if got.RootfsPath != rootfs || got.KernelPath != kernel || got.InitrdPath != initrd || got.ModulesDir != modulesDir || got.PackagesPath != packages { t.Fatalf("stale default not reconciled: %+v", got) } if !got.UpdatedAt.After(stale.UpdatedAt) { t.Fatalf("UpdatedAt = %s, want newer than %s", got.UpdatedAt, stale.UpdatedAt) } gotVM, err := db.GetVMByID(context.Background(), vm.ID) if err != nil { t.Fatalf("GetVMByID: %v", err) } if gotVM.ImageID != stale.ID { t.Fatalf("VM image ID = %q, want preserved %q", gotVM.ImageID, stale.ID) } } func TestEnsureDefaultImageLeavesManagedDefaultUntouched(t *testing.T) { dir := t.TempDir() rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir) db := openDefaultImageStore(t, dir) now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) managed := model.Image{ ID: "managed-default", Name: "default", Managed: true, RootfsPath: "/managed/rootfs.ext4", KernelPath: "/managed/vmlinux", CreatedAt: now, UpdatedAt: now, } if err := db.UpsertImage(context.Background(), managed); err != nil { t.Fatalf("UpsertImage: %v", err) } d := &Daemon{ config: model.DaemonConfig{ DefaultImageName: "default", DefaultRootfs: rootfs, DefaultKernel: kernel, }, store: db, } if err := d.ensureDefaultImage(context.Background()); err != nil { t.Fatalf("ensureDefaultImage: %v", err) } got, err := db.GetImageByName(context.Background(), "default") if err != nil { t.Fatalf("GetImageByName: %v", err) } if got.RootfsPath != managed.RootfsPath || got.KernelPath != managed.KernelPath { t.Fatalf("managed default was rewritten: %+v", got) } } func TestEnsureDefaultImageSkipsRewriteWhenCurrentArtifactsMissing(t *testing.T) { dir := t.TempDir() db := openDefaultImageStore(t, dir) now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) stale := model.Image{ ID: "default-id", Name: "default", Managed: false, RootfsPath: "/old/rootfs.ext4", KernelPath: "/old/vmlinux", CreatedAt: now, UpdatedAt: now, } if err := db.UpsertImage(context.Background(), stale); err != nil { t.Fatalf("UpsertImage: %v", err) } d := &Daemon{ config: model.DaemonConfig{ DefaultImageName: "default", DefaultRootfs: filepath.Join(dir, "missing-rootfs.ext4"), DefaultKernel: filepath.Join(dir, "missing-vmlinux"), }, store: db, } if err := d.ensureDefaultImage(context.Background()); err != nil { t.Fatalf("ensureDefaultImage: %v", err) } got, err := db.GetImageByName(context.Background(), "default") if err != nil { t.Fatalf("GetImageByName: %v", err) } if got.RootfsPath != stale.RootfsPath || got.KernelPath != stale.KernelPath { t.Fatalf("default image should have stayed stale when no current artifacts exist: %+v", got) } } func openDefaultImageStore(t *testing.T, dir string) *store.Store { t.Helper() db, err := store.Open(filepath.Join(dir, "state.db")) if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { _ = db.Close() }) return db } func writeDefaultImageArtifacts(t *testing.T, dir string) (rootfs, kernel, initrd, modulesDir, packages string) { t.Helper() rootfs = filepath.Join(dir, "rootfs-docker.ext4") kernel = filepath.Join(dir, "vmlinux") initrd = filepath.Join(dir, "initrd.img") modulesDir = filepath.Join(dir, "modules") packages = filepath.Join(dir, "packages.apt") files := []string{ rootfs, kernel, initrd, packages, filepath.Join(modulesDir, "modules.dep"), } for _, path := range files { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) } if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } return rootfs, kernel, initrd, modulesDir, packages } func TestSetDNSUsesConfiguredMapDNSDataFile(t *testing.T) { t.Parallel() dataFile := filepath.Join(t.TempDir(), "mapdns", "records.json") runner := &scriptedRunner{ t: t, steps: []runnerStep{ { call: runnerCall{ name: "custom-mapdns", args: []string{"set", "--data-file", dataFile, "devbox.vm", "172.16.0.8"}, }, }, }, } d := &Daemon{ runner: runner, config: model.DaemonConfig{ MapDNSBin: "custom-mapdns", MapDNSDataFile: dataFile, }, } if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil { t.Fatalf("setDNS: %v", err) } runner.assertExhausted() } func TestSetDNSUsesMapDNSDefaultsWhenDataFileUnset(t *testing.T) { t.Parallel() runner := &scriptedRunner{ t: t, steps: []runnerStep{ { call: runnerCall{ name: "mapdns", args: []string{"set", "devbox.vm", "172.16.0.8"}, }, }, }, } d := &Daemon{ runner: runner, config: model.DaemonConfig{}, } if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil { t.Fatalf("setDNS: %v", err) } runner.assertExhausted() } func TestDispatchUsesPassedContext(t *testing.T) { t.Parallel() db := openDefaultImageStore(t, t.TempDir()) d := &Daemon{store: db} ctx, cancel := context.WithCancel(context.Background()) cancel() resp := d.dispatch(ctx, rpc.Request{ Version: rpc.Version, Method: "vm.list", Params: mustJSON(t, api.Empty{}), }) if resp.OK { t.Fatal("dispatch() succeeded with canceled context") } if resp.Error == nil || !strings.Contains(resp.Error.Message, context.Canceled.Error()) { t.Fatalf("dispatch() error = %+v, want context canceled", resp.Error) } } func TestHandleConnCancelsRequestWhenClientDisconnects(t *testing.T) { t.Parallel() server, client := net.Pipe() defer client.Close() requestCanceled := make(chan struct{}) done := make(chan struct{}) d := &Daemon{ closing: make(chan struct{}), requestHandler: func(ctx context.Context, req rpc.Request) rpc.Response { if req.Method != "block" { t.Errorf("request method = %q, want block", req.Method) } <-ctx.Done() close(requestCanceled) return rpc.NewError("operation_failed", ctx.Err().Error()) }, } go func() { d.handleConn(server) close(done) }() if err := json.NewEncoder(client).Encode(rpc.Request{Version: rpc.Version, Method: "block"}); err != nil { t.Fatalf("encode request: %v", err) } if err := client.Close(); err != nil { t.Fatalf("close client: %v", err) } select { case <-requestCanceled: case <-time.After(2 * time.Second): t.Fatal("request context was not canceled after client disconnect") } select { case <-done: case <-time.After(2 * time.Second): t.Fatal("handleConn did not return after client disconnect") } } func TestWatchRequestDisconnectCancelsContextOnEOF(t *testing.T) { t.Parallel() server, client := net.Pipe() defer server.Close() reader := bufio.NewReader(server) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) d := &Daemon{closing: make(chan struct{})} stop := d.watchRequestDisconnect(server, reader, "block", cancel) defer stop() if err := client.Close(); err != nil { t.Fatalf("close client: %v", err) } select { case <-ctx.Done(): if !strings.Contains(ctx.Err().Error(), context.Canceled.Error()) { t.Fatalf("ctx.Err() = %v, want canceled", ctx.Err()) } case <-time.After(2 * time.Second): t.Fatal("watchRequestDisconnect did not cancel context") } } func mustJSON(t *testing.T, v any) []byte { t.Helper() data, err := json.Marshal(v) if err != nil { t.Fatalf("json.Marshal(%T): %v", v, err) } return data }