From ebb68c3126a36750e5bf76eeb376435b289ae435 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Mon, 16 Mar 2026 16:45:54 -0300 Subject: [PATCH] Reconcile stale default image on startup Upgrades from the pre-bundle layout can leave the unmanaged default image pointing at repo-root artifacts that no longer exist, causing vm start preflight to fail forever. Treat the default image as reconcilable state instead of bailing out when a record exists. Compute the desired default image from current runtime/config defaults, update an existing unmanaged default in place when its paths diverge, keep managed defaults untouched, and preserve the original ID so existing VMs keep referencing it. Added regression tests covering creation, no-op, reconciliation, managed-image skip, and missing-artifact behavior. --- internal/daemon/daemon.go | 93 ++++++++---- internal/daemon/daemon_test.go | 254 +++++++++++++++++++++++++++++++-- 2 files changed, 308 insertions(+), 39 deletions(-) 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) {