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.
This commit is contained in:
parent
6e00fa690d
commit
ebb68c3126
2 changed files with 308 additions and 39 deletions
|
|
@ -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("default image skipped", "image_name", d.config.DefaultImageName, "rootfs_path", rootfs, "kernel_path", kernel)
|
||||
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.Info("default image reconciled", append(imageLogAttrs(updated), "previous_rootfs_path", image.RootfsPath, "previous_kernel_path", image.KernelPath)...)
|
||||
}
|
||||
return nil
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
id, err := model.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := model.Now()
|
||||
image := model.Image{
|
||||
ID: id,
|
||||
Name: d.config.DefaultImageName,
|
||||
Managed: false,
|
||||
RootfsPath: rootfs,
|
||||
KernelPath: kernel,
|
||||
InitrdPath: initrd,
|
||||
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 {
|
||||
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(image), "managed", image.Managed)...)
|
||||
d.logger.Info("default image registered", append(imageLogAttrs(desired), "managed", desired.Managed)...)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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: d.config.DefaultInitrd,
|
||||
ModulesDir: d.config.DefaultModulesDir,
|
||||
PackagesPath: d.config.DefaultPackagesFile,
|
||||
Docker: strings.Contains(filepath.Base(rootfs), "docker"),
|
||||
}, 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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue