diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c337719..61a28bf 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -3,6 +3,7 @@ package daemon import ( "bufio" "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -310,46 +311,90 @@ func (d *Daemon) ensureDefaultImage(ctx context.Context) error { if d.config.DefaultImageName == "" { return nil } - if _, err := d.store.GetImageByName(ctx, d.config.DefaultImageName); err == nil { + desired, ok := d.desiredDefaultImage() + if !ok { if d.logger != nil { - d.logger.Debug("default image already registered", "image_name", d.config.DefaultImageName) + d.logger.Debug("default image skipped", "image_name", d.config.DefaultImageName, "rootfs_path", d.config.DefaultRootfs, "kernel_path", d.config.DefaultKernel) } return nil } - rootfs := d.config.DefaultRootfs - kernel := d.config.DefaultKernel - initrd := d.config.DefaultInitrd - if !exists(rootfs) || !exists(kernel) { + + image, err := d.store.GetImageByName(ctx, d.config.DefaultImageName) + switch { + case err == nil: + if image.Managed { + if d.logger != nil { + d.logger.Debug("managed default image left untouched", append(imageLogAttrs(image), "managed", image.Managed)...) + } + return nil + } + if defaultImageMatches(image, desired) { + if d.logger != nil { + d.logger.Debug("default image already current", imageLogAttrs(image)...) + } + return nil + } + updated := desired + updated.ID = image.ID + updated.CreatedAt = image.CreatedAt + updated.UpdatedAt = model.Now() + if err := d.store.UpsertImage(ctx, updated); err != nil { + return err + } if d.logger != nil { - d.logger.Debug("default image skipped", "image_name", d.config.DefaultImageName, "rootfs_path", rootfs, "kernel_path", kernel) + d.logger.Info("default image reconciled", append(imageLogAttrs(updated), "previous_rootfs_path", image.RootfsPath, "previous_kernel_path", image.KernelPath)...) } return nil - } - id, err := model.NewID() - if err != nil { + case errors.Is(err, sql.ErrNoRows): + id, err := model.NewID() + if err != nil { + return err + } + now := model.Now() + desired.ID = id + desired.CreatedAt = now + desired.UpdatedAt = now + if err := d.store.UpsertImage(ctx, desired); err != nil { + return err + } + if d.logger != nil { + d.logger.Info("default image registered", append(imageLogAttrs(desired), "managed", desired.Managed)...) + } + return nil + default: return err } - now := model.Now() - image := model.Image{ - ID: id, +} + +func (d *Daemon) desiredDefaultImage() (model.Image, bool) { + rootfs := d.config.DefaultRootfs + kernel := d.config.DefaultKernel + if !exists(rootfs) || !exists(kernel) { + return model.Image{}, false + } + return model.Image{ Name: d.config.DefaultImageName, Managed: false, + ArtifactDir: "", RootfsPath: rootfs, KernelPath: kernel, - InitrdPath: initrd, + InitrdPath: d.config.DefaultInitrd, ModulesDir: d.config.DefaultModulesDir, PackagesPath: d.config.DefaultPackagesFile, Docker: strings.Contains(filepath.Base(rootfs), "docker"), - CreatedAt: now, - UpdatedAt: now, - } - if err := d.store.UpsertImage(ctx, image); err != nil { - return err - } - if d.logger != nil { - d.logger.Info("default image registered", append(imageLogAttrs(image), "managed", image.Managed)...) - } - return nil + }, true +} + +func defaultImageMatches(current, desired model.Image) bool { + return current.Name == desired.Name && + current.Managed == desired.Managed && + current.ArtifactDir == desired.ArtifactDir && + current.RootfsPath == desired.RootfsPath && + current.KernelPath == desired.KernelPath && + current.InitrdPath == desired.InitrdPath && + current.ModulesDir == desired.ModulesDir && + current.PackagesPath == desired.PackagesPath && + current.Docker == desired.Docker } func (d *Daemon) reconcile(ctx context.Context) error { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 1e962b8..eba22b2 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" "banger/internal/model" "banger/internal/store" @@ -12,21 +13,8 @@ import ( func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) { dir := t.TempDir() - rootfs := filepath.Join(dir, "rootfs-docker.ext4") - kernel := filepath.Join(dir, "vmlinux") - for _, path := range []string{rootfs, kernel} { - if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } - } - - db, err := store.Open(filepath.Join(dir, "state.db")) - if err != nil { - t.Fatalf("open store: %v", err) - } - t.Cleanup(func() { - _ = db.Close() - }) + rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir) + db := openDefaultImageStore(t, dir) d := &Daemon{ config: model.DaemonConfig{ @@ -51,6 +39,242 @@ func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) { 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) {