Remove runtime-bundle image dependencies

Hard-cut banger away from source-checkout runtime bundles as an implicit source of\nimage and host defaults. Managed images now own their full boot set,\nimage build starts from an existing registered image, and daemon startup\nno longer synthesizes a default image from host paths.\n\nResolve Firecracker from PATH or firecracker_bin, make SSH keys config-owned\nwith an auto-managed XDG default, replace the external name generator and\npackage manifests with Go code, and keep the vsock helper as a companion\nbinary instead of a user-managed runtime asset.\n\nUpdate the manual scripts, web/CLI forms, config surface, and docs around\nthe new build/manual flow and explicit image registration semantics.\n\nValidation: GOCACHE=/tmp/banger-gocache go test ./..., bash -n scripts/*.sh,\nand make build.
This commit is contained in:
Thales Maciel 2026-03-21 18:34:53 -03:00
parent 01c7cb5e65
commit 572bf32424
No known key found for this signature in database
GPG key ID: 33112E6833C34679
44 changed files with 1194 additions and 3456 deletions

View file

@ -208,11 +208,13 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.
}
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {
if strings.TrimSpace(d.config.DefaultWorkSeed) != "" && exists(d.config.DefaultWorkSeed) {
checks := system.NewPreflight()
checks.RequireFile(d.config.DefaultWorkSeed, "default work seed image", `rebuild the default runtime rootfs to regenerate the /root seed`)
report.AddPreflight("feature /root work disk", checks, "seeded /root work disk artifact available")
return
if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" {
if image, err := d.store.GetImageByName(context.Background(), d.config.DefaultImageName); err == nil && strings.TrimSpace(image.WorkSeedPath) != "" && exists(image.WorkSeedPath) {
checks := system.NewPreflight()
checks.RequireFile(image.WorkSeedPath, "default image work-seed", `rebuild the default image to regenerate the /root seed`)
report.AddPreflight("feature /root work disk", checks, "seeded /root work disk artifact available")
return
}
}
checks := system.NewPreflight()
for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} {

View file

@ -11,7 +11,6 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@ -85,7 +84,7 @@ func Open(ctx context.Context) (d *Daemon, err error) {
closing: make(chan struct{}),
pid: os.Getpid(),
}
d.logger.Info("daemon opened", "socket", layout.SocketPath, "state_dir", layout.StateDir, "runtime_dir", cfg.RuntimeDir, "log_level", cfg.LogLevel)
d.logger.Info("daemon opened", "socket", layout.SocketPath, "state_dir", layout.StateDir, "log_level", cfg.LogLevel)
if err = d.startVMDNS(vmdns.DefaultListenAddr); err != nil {
d.logger.Error("daemon open failed", "stage", "start_vm_dns", "error", err.Error())
return nil, err
@ -95,10 +94,6 @@ func Open(ctx context.Context) (d *Daemon, err error) {
_ = d.stopVMDNS()
}
}()
if err = d.ensureDefaultImage(ctx); err != nil {
d.logger.Error("daemon open failed", "stage", "ensure_default_image", "error", err.Error())
return nil, err
}
if err = d.reconcile(ctx); err != nil {
d.logger.Error("daemon open failed", "stage", "reconcile", "error", err.Error())
return nil, err
@ -499,95 +494,8 @@ func (d *Daemon) stopVMDNS() error {
}
func (d *Daemon) ensureDefaultImage(ctx context.Context) error {
if d.config.DefaultImageName == "" {
return nil
}
desired, ok := d.desiredDefaultImage()
if !ok {
if d.logger != nil {
d.logger.Debug("default image skipped", "image_name", d.config.DefaultImageName, "rootfs_path", d.config.DefaultRootfs, "kernel_path", d.config.DefaultKernel)
}
return nil
}
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.Info("default image reconciled", append(imageLogAttrs(updated), "previous_rootfs_path", image.RootfsPath, "previous_work_seed_path", image.WorkSeedPath, "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()
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
}
}
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,
WorkSeedPath: d.config.DefaultWorkSeed,
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.WorkSeedPath == desired.WorkSeedPath &&
current.KernelPath == desired.KernelPath &&
current.InitrdPath == desired.InitrdPath &&
current.ModulesDir == desired.ModulesDir &&
current.PackagesPath == desired.PackagesPath &&
current.Docker == desired.Docker
_ = ctx
return nil
}
func (d *Daemon) reconcile(ctx context.Context) error {

View file

@ -1,722 +1,106 @@
package daemon
import (
"bufio"
"bytes"
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"banger/internal/api"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/rpc"
"banger/internal/store"
"banger/internal/system"
)
func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) {
dir := t.TempDir()
rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir)
db := openDefaultImageStore(t, dir)
func TestBuildImageRequiresFromImage(t *testing.T) {
d := &Daemon{
config: model.DaemonConfig{
DefaultImageName: "default",
DefaultRootfs: rootfs,
DefaultKernel: kernel,
},
store: db,
layout: paths.Layout{ImagesDir: t.TempDir(), StateDir: t.TempDir()},
store: openDaemonStore(t),
runner: system.NewRunner(),
}
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")
_, err := d.BuildImage(context.Background(), api.ImageBuildParams{Name: "missing-base"})
if err == nil || !strings.Contains(err.Error(), "from-image is required") {
t.Fatalf("BuildImage() error = %v", err)
}
}
func TestEnsureDefaultImageLeavesCurrentUnmanagedDefaultUntouched(t *testing.T) {
func TestRegisterImageRequiresKernel(t *testing.T) {
rootfs := filepath.Join(t.TempDir(), "rootfs.ext4")
if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil {
t.Fatalf("write rootfs: %v", err)
}
d := &Daemon{store: openDaemonStore(t)}
_, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "missing-kernel",
RootfsPath: rootfs,
})
if err == nil || !strings.Contains(err.Error(), "kernel path is required") {
t.Fatalf("RegisterImage() error = %v", err)
}
}
func TestPromoteImageCopiesBootArtifactsIntoArtifactDir(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)
rootfs := filepath.Join(dir, "rootfs.ext4")
kernel := filepath.Join(dir, "vmlinux")
initrd := filepath.Join(dir, "initrd.img")
modulesDir := filepath.Join(dir, "modules")
if err := os.MkdirAll(modulesDir, 0o755); err != nil {
t.Fatalf("mkdir modules: %v", err)
}
for path, data := range map[string]string{
rootfs: "rootfs",
kernel: "kernel",
initrd: "initrd",
filepath.Join(modulesDir, "depmod"): "modules",
} {
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
db := openDaemonStore(t)
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,
ID: "img-promote",
Name: "void-exp",
Managed: false,
RootfsPath: rootfs,
KernelPath: kernel,
InitrdPath: initrd,
ModulesDir: modulesDir,
CreatedAt: model.Now(),
UpdatedAt: model.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/build/runtime/rootfs-docker.ext4",
KernelPath: "/home/thales/projects/personal/banger/build/runtime/wtf/root/boot/vmlinux-6.8.0-94-generic",
InitrdPath: "/home/thales/projects/personal/banger/build/runtime/wtf/root/boot/initrd.img-6.8.0-94-generic",
ModulesDir: "/home/thales/projects/personal/banger/build/runtime/wtf/root/lib/modules/6.8.0-94-generic",
PackagesPath: "/home/thales/projects/personal/banger/build/runtime/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)
imagesDir := filepath.Join(dir, "images")
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
t.Fatalf("mkdir images dir: %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 TestRegisterImageCreatesUnmanagedImage(t *testing.T) {
dir := t.TempDir()
rootfs, kernel, initrd, modulesDir, _ := writeDefaultImageArtifacts(t, dir)
workSeed := filepath.Join(dir, "rootfs-void.work-seed.ext4")
packages := filepath.Join(dir, "packages.void")
if err := os.WriteFile(workSeed, []byte("seed"), 0o644); err != nil {
t.Fatalf("WriteFile(workSeed): %v", err)
}
if err := os.WriteFile(packages, []byte("base-minimal\nopenssh\n"), 0o644); err != nil {
t.Fatalf("WriteFile(packages): %v", err)
}
db := openDefaultImageStore(t, dir)
d := &Daemon{
config: model.DaemonConfig{
DefaultKernel: kernel,
DefaultInitrd: initrd,
DefaultModulesDir: modulesDir,
},
store: db,
}
image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "void-exp",
RootfsPath: rootfs,
WorkSeedPath: workSeed,
PackagesPath: packages,
})
if err != nil {
t.Fatalf("RegisterImage: %v", err)
}
if image.Managed {
t.Fatal("registered image should be unmanaged")
}
if image.Name != "void-exp" || image.RootfsPath != rootfs || image.WorkSeedPath != workSeed || image.KernelPath != kernel {
t.Fatalf("registered image = %+v", image)
}
}
func TestRegisterImageUpdatesExistingUnmanagedImageInPlace(t *testing.T) {
dir := t.TempDir()
_, kernel, initrd, modulesDir, _ := writeDefaultImageArtifacts(t, dir)
newRootfs := filepath.Join(dir, "rootfs-void-next.ext4")
newWorkSeed := filepath.Join(dir, "rootfs-void-next.work-seed.ext4")
packages := filepath.Join(dir, "packages.void")
for _, path := range []string{newRootfs, newWorkSeed} {
if err := os.WriteFile(path, []byte("next"), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
if err := os.WriteFile(packages, []byte("base-minimal\n"), 0o644); err != nil {
t.Fatalf("WriteFile(packages): %v", err)
}
db := openDefaultImageStore(t, dir)
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
existing := model.Image{
ID: "void-image-id",
Name: "void-exp",
Managed: false,
RootfsPath: filepath.Join(dir, "old-rootfs.ext4"),
KernelPath: kernel,
InitrdPath: initrd,
ModulesDir: modulesDir,
PackagesPath: packages,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.UpsertImage(context.Background(), existing); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
d := &Daemon{
config: model.DaemonConfig{
DefaultKernel: kernel,
DefaultInitrd: initrd,
DefaultModulesDir: modulesDir,
},
store: db,
}
image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "void-exp",
RootfsPath: newRootfs,
WorkSeedPath: newWorkSeed,
PackagesPath: packages,
})
if err != nil {
t.Fatalf("RegisterImage: %v", err)
}
if image.ID != existing.ID || !image.CreatedAt.Equal(existing.CreatedAt) {
t.Fatalf("updated image identity changed: %+v", image)
}
if image.RootfsPath != newRootfs || image.WorkSeedPath != newWorkSeed {
t.Fatalf("updated image paths not applied: %+v", image)
}
}
func TestRegisterImageRejectsManagedOverwrite(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)
if err := db.UpsertImage(context.Background(), model.Image{
ID: "managed-id",
Name: "void-exp",
Managed: true,
RootfsPath: rootfs,
KernelPath: kernel,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
d := &Daemon{config: model.DaemonConfig{DefaultKernel: kernel}, store: db}
_, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "void-exp",
RootfsPath: rootfs,
})
if err == nil || !strings.Contains(err.Error(), "cannot be updated via register") {
t.Fatalf("RegisterImage(managed) error = %v", err)
}
}
func TestPromoteImageCopiesArtifactsAndPreservesIdentity(t *testing.T) {
dir := t.TempDir()
rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir)
workSeed := filepath.Join(dir, "rootfs-docker.work-seed.ext4")
workSeedContent := []byte("seed-data")
if err := os.WriteFile(workSeed, workSeedContent, 0o644); err != nil {
t.Fatalf("WriteFile(workSeed): %v", err)
}
db := openDefaultImageStore(t, dir)
now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC)
existing := model.Image{
ID: "promote-image-id",
Name: "default",
Managed: false,
RootfsPath: rootfs,
WorkSeedPath: workSeed,
KernelPath: kernel,
InitrdPath: initrd,
ModulesDir: modulesDir,
PackagesPath: packages,
Docker: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.UpsertImage(context.Background(), existing); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
vm := testVM("uses-default", existing.ID, "172.16.0.44")
if err := db.UpsertVM(context.Background(), vm); err != nil {
t.Fatalf("UpsertVM: %v", err)
}
d := &Daemon{
layout: modelPathsLayoutForTest(dir),
layout: paths.Layout{ImagesDir: imagesDir},
store: db,
runner: system.NewRunner(),
}
image, err := d.PromoteImage(context.Background(), "default")
got, err := d.PromoteImage(context.Background(), image.Name)
if err != nil {
t.Fatalf("PromoteImage: %v", err)
}
if !image.Managed {
if !got.Managed {
t.Fatal("promoted image should be managed")
}
if image.ID != existing.ID || image.Name != existing.Name {
t.Fatalf("promoted image identity changed: %+v", image)
}
if !image.CreatedAt.Equal(existing.CreatedAt) {
t.Fatalf("CreatedAt = %s, want preserved %s", image.CreatedAt, existing.CreatedAt)
}
if !image.UpdatedAt.After(existing.UpdatedAt) {
t.Fatalf("UpdatedAt = %s, want newer than %s", image.UpdatedAt, existing.UpdatedAt)
}
wantArtifactDir := filepath.Join(d.layout.ImagesDir, existing.ID)
if image.ArtifactDir != wantArtifactDir {
t.Fatalf("ArtifactDir = %q, want %q", image.ArtifactDir, wantArtifactDir)
}
if image.RootfsPath != filepath.Join(wantArtifactDir, "rootfs.ext4") {
t.Fatalf("RootfsPath = %q, want managed copy", image.RootfsPath)
}
if image.WorkSeedPath != filepath.Join(wantArtifactDir, "work-seed.ext4") {
t.Fatalf("WorkSeedPath = %q, want managed copy", image.WorkSeedPath)
}
if image.KernelPath != kernel || image.InitrdPath != initrd || image.ModulesDir != modulesDir || image.PackagesPath != packages {
t.Fatalf("boot support paths changed unexpectedly: %+v", image)
}
rootfsContent, err := os.ReadFile(rootfs)
if err != nil {
t.Fatalf("ReadFile(rootfs): %v", err)
}
managedRootfsContent, err := os.ReadFile(image.RootfsPath)
if err != nil {
t.Fatalf("ReadFile(managed rootfs): %v", err)
}
if !bytes.Equal(managedRootfsContent, rootfsContent) {
t.Fatal("managed rootfs copy content mismatch")
}
managedWorkSeedContent, err := os.ReadFile(image.WorkSeedPath)
if err != nil {
t.Fatalf("ReadFile(managed work seed): %v", err)
}
if !bytes.Equal(managedWorkSeedContent, workSeedContent) {
t.Fatal("managed work seed copy content mismatch")
}
got, err := db.GetImageByName(context.Background(), "default")
if err != nil {
t.Fatalf("GetImageByName: %v", err)
}
if got.RootfsPath != image.RootfsPath || !got.Managed || got.ArtifactDir != image.ArtifactDir {
t.Fatalf("stored promoted image = %+v, want %+v", got, image)
}
gotVM, err := db.GetVMByID(context.Background(), vm.ID)
if err != nil {
t.Fatalf("GetVMByID: %v", err)
}
if gotVM.ImageID != existing.ID {
t.Fatalf("VM image ID = %q, want preserved %q", gotVM.ImageID, existing.ID)
}
}
func TestPromoteImageRejectsManagedImage(t *testing.T) {
dir := t.TempDir()
rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir)
db := openDefaultImageStore(t, dir)
now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC)
if err := db.UpsertImage(context.Background(), model.Image{
ID: "managed-id",
Name: "default",
Managed: true,
ArtifactDir: filepath.Join(dir, "images", "managed-id"),
RootfsPath: rootfs,
KernelPath: kernel,
InitrdPath: initrd,
ModulesDir: modulesDir,
PackagesPath: packages,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
d := &Daemon{
layout: modelPathsLayoutForTest(dir),
store: db,
}
_, err := d.PromoteImage(context.Background(), "default")
if err == nil || !strings.Contains(err.Error(), "already managed") {
t.Fatalf("PromoteImage(managed) error = %v", err)
}
}
func TestPromoteImageSkipsMissingWorkSeed(t *testing.T) {
dir := t.TempDir()
rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir)
db := openDefaultImageStore(t, dir)
now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC)
existing := model.Image{
ID: "promote-missing-seed",
Name: "default",
Managed: false,
RootfsPath: rootfs,
WorkSeedPath: filepath.Join(dir, "missing.work-seed.ext4"),
KernelPath: kernel,
InitrdPath: initrd,
ModulesDir: modulesDir,
PackagesPath: packages,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.UpsertImage(context.Background(), existing); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
d := &Daemon{
layout: modelPathsLayoutForTest(dir),
store: db,
}
image, err := d.PromoteImage(context.Background(), "default")
if err != nil {
t.Fatalf("PromoteImage: %v", err)
}
if image.WorkSeedPath != "" {
t.Fatalf("WorkSeedPath = %q, want empty for missing source work seed", image.WorkSeedPath)
}
if _, err := os.Stat(filepath.Join(image.ArtifactDir, "work-seed.ext4")); !os.IsNotExist(err) {
t.Fatalf("managed work-seed should not exist, stat error = %v", err)
}
}
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)
for _, path := range []string{got.RootfsPath, got.KernelPath, got.InitrdPath, got.ModulesDir} {
if !strings.HasPrefix(path, got.ArtifactDir) {
t.Fatalf("artifact path %q does not live under %q", path, got.ArtifactDir)
}
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
if _, err := os.Stat(path); err != nil {
t.Fatalf("stat %s: %v", path, err)
}
}
return rootfs, kernel, initrd, modulesDir, packages
}
func modelPathsLayoutForTest(dir string) paths.Layout {
return paths.Layout{
ImagesDir: filepath.Join(dir, "images"),
}
}
func TestStartVMDNSFailsWhenAddressBusy(t *testing.T) {
t.Parallel()
packetConn, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("ListenPacket: %v", err)
}
defer packetConn.Close()
d := &Daemon{}
if err := d.startVMDNS(packetConn.LocalAddr().String()); err == nil {
t.Fatal("startVMDNS() succeeded on occupied address, want failure")
}
}
func TestSetDNSPublishesIntoDaemonServer(t *testing.T) {
t.Parallel()
d := &Daemon{}
if err := d.startVMDNS("127.0.0.1:0"); err != nil {
t.Fatalf("startVMDNS: %v", err)
}
defer d.stopVMDNS()
if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil {
t.Fatalf("setDNS: %v", err)
}
if _, ok := d.vmDNS.Lookup("devbox.vm"); !ok {
t.Fatal("devbox.vm missing after setDNS")
}
}
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
}

View file

@ -2,12 +2,13 @@ package daemon
import (
"context"
"fmt"
"database/sql"
"strings"
"banger/internal/config"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/store"
"banger/internal/system"
)
@ -25,34 +26,49 @@ func Doctor(ctx context.Context) (system.Report, error) {
config: cfg,
runner: system.NewRunner(),
}
db, err := store.Open(layout.DBPath)
if err == nil {
defer db.Close()
d.store = db
}
return d.doctorReport(ctx), nil
}
func (d *Daemon) doctorReport(ctx context.Context) system.Report {
report := system.Report{}
report.AddPreflight("runtime bundle", d.runtimeBundleChecks(), runtimeBundleStatus(d.config))
report.AddPreflight("host runtime", d.runtimeChecks(), runtimeStatus(d.config))
report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available")
report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock agent prerequisites available")
report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available")
d.addCapabilityDoctorChecks(ctx, &report)
report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available")
return report
}
func (d *Daemon) runtimeBundleChecks() *system.Preflight {
func (d *Daemon) runtimeChecks() *system.Preflight {
checks := system.NewPreflight()
hint := paths.RuntimeBundleHint()
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
checks.RequireFile(d.config.DefaultRootfs, "default rootfs image", `set "default_rootfs" or refresh the runtime bundle`)
checks.RequireFile(d.config.DefaultKernel, "kernel image", `set "default_kernel" or refresh the runtime bundle`)
if strings.TrimSpace(d.config.DefaultInitrd) != "" {
checks.RequireFile(d.config.DefaultInitrd, "initrd image", `set "default_initrd" or refresh the runtime bundle`)
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`)
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`)
if helper, err := d.vsockAgentBinary(); err == nil {
checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`)
} else {
checks.Addf("%v", err)
}
if strings.TrimSpace(d.config.DefaultPackagesFile) != "" {
checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`)
if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" {
image, err := d.store.GetImageByName(context.Background(), d.config.DefaultImageName)
switch {
case err == nil:
checks.RequireFile(image.RootfsPath, "default image rootfs", `re-register or rebuild the default image`)
checks.RequireFile(image.KernelPath, "default image kernel", `re-register or rebuild the default image`)
if strings.TrimSpace(image.InitrdPath) != "" {
checks.RequireFile(image.InitrdPath, "default image initrd", `re-register or rebuild the default image`)
}
case err != nil && err != sql.ErrNoRows:
checks.Addf("failed to inspect default image %q: %v", d.config.DefaultImageName, err)
default:
checks.Addf("default image %q is not registered", d.config.DefaultImageName)
}
}
return checks
}
@ -65,37 +81,33 @@ func (d *Daemon) coreVMLifecycleChecks() *system.Preflight {
func (d *Daemon) imageBuildChecks(ctx context.Context) *system.Preflight {
checks := system.NewPreflight()
d.addImageBuildPrereqs(
ctx,
checks,
firstNonEmpty(d.config.DefaultBaseRootfs, d.config.DefaultRootfs),
d.config.DefaultKernel,
d.config.DefaultInitrd,
d.config.DefaultModulesDir,
"",
)
if d.store == nil || strings.TrimSpace(d.config.DefaultImageName) == "" {
checks.Addf("default image is not available for build inheritance")
return checks
}
image, err := d.store.GetImageByName(ctx, d.config.DefaultImageName)
if err != nil {
checks.Addf("default image %q is not registered", d.config.DefaultImageName)
return checks
}
d.addImageBuildPrereqs(ctx, checks, image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir, "")
return checks
}
func (d *Daemon) vsockChecks() *system.Preflight {
checks := system.NewPreflight()
checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
if helper, err := d.vsockAgentBinary(); err == nil {
checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`)
} else {
checks.Addf("%v", err)
}
checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host")
return checks
}
func runtimeBundleStatus(cfg model.DaemonConfig) string {
if strings.TrimSpace(cfg.RuntimeDir) == "" {
return "runtime dir not configured"
func runtimeStatus(cfg model.DaemonConfig) string {
if strings.TrimSpace(cfg.FirecrackerBin) == "" {
return "firecracker not configured"
}
return fmt.Sprintf("runtime dir %s", cfg.RuntimeDir)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
return "firecracker and ssh key resolved"
}

View file

@ -3,7 +3,6 @@ package daemon
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
@ -16,6 +15,7 @@ import (
"banger/internal/guest"
"banger/internal/guestnet"
"banger/internal/hostnat"
"banger/internal/imagepreset"
"banger/internal/model"
"banger/internal/opencode"
"banger/internal/system"
@ -39,13 +39,13 @@ const (
type imageBuildSpec struct {
ID string
Name string
BaseRootfs string
SourceRootfs string
RootfsPath string
BuildLog io.Writer
KernelPath string
InitrdPath string
ModulesDir string
PackagesPath string
Packages []string
InstallDocker bool
Size string
}
@ -66,15 +66,11 @@ func (d *Daemon) runImageBuild(ctx context.Context, spec imageBuildSpec) error {
}
func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (err error) {
packages, err := system.ReadNormalizedLines(spec.PackagesPath)
if err != nil {
return err
}
if err := system.CopyFilePreferClone(spec.BaseRootfs, spec.RootfsPath); err != nil {
if err := system.CopyFilePreferClone(spec.SourceRootfs, spec.RootfsPath); err != nil {
return err
}
if spec.Size != "" {
if err := resizeRootfs(spec.BaseRootfs, spec.RootfsPath, spec.Size); err != nil {
if err := resizeRootfs(spec.SourceRootfs, spec.RootfsPath, spec.Size); err != nil {
return err
}
}
@ -110,7 +106,11 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (
return err
}
helperBytes, err := os.ReadFile(d.config.VSockAgentPath)
vsockAgentPath, err := d.vsockAgentBinary()
if err != nil {
return err
}
helperBytes, err := os.ReadFile(vsockAgentPath)
if err != nil {
return err
}
@ -123,7 +123,7 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (
if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil {
return err
}
if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), packages, spec.InstallDocker), spec.BuildLog); err != nil {
if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), spec.Packages, spec.InstallDocker), spec.BuildLog); err != nil {
return err
}
if strings.TrimSpace(spec.ModulesDir) != "" {
@ -428,6 +428,5 @@ func writeBuildLog(w io.Writer, message string) error {
}
func packagesHash(lines []string) string {
sum := sha256.Sum256([]byte(strings.Join(lines, "\n") + "\n"))
return fmt.Sprintf("%x", sum)
return imagepreset.Hash(lines)
}

View file

@ -10,8 +10,8 @@ import (
"strings"
"banger/internal/api"
"banger/internal/imagepreset"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
)
@ -37,12 +37,13 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i
if _, err := d.FindImage(ctx, name); err == nil {
return model.Image{}, fmt.Errorf("image name already exists: %s", name)
}
baseRootfs := params.BaseRootfs
if baseRootfs == "" {
baseRootfs = d.config.DefaultBaseRootfs
fromImage := strings.TrimSpace(params.FromImage)
if fromImage == "" {
return model.Image{}, fmt.Errorf("from-image is required")
}
if baseRootfs == "" {
return model.Image{}, fmt.Errorf("base rootfs is required; %s", paths.RuntimeBundleHint())
baseImage, err := d.FindImage(ctx, fromImage)
if err != nil {
return model.Image{}, err
}
id, err := model.NewID()
if err != nil {
@ -50,9 +51,6 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i
}
now := model.Now()
artifactDir := filepath.Join(d.layout.ImagesDir, id)
if err := os.MkdirAll(artifactDir, 0o755); err != nil {
return model.Image{}, err
}
buildLogDir := filepath.Join(d.layout.StateDir, "image-build")
if err := os.MkdirAll(buildLogDir, 0o755); err != nil {
return model.Image{}, err
@ -64,73 +62,80 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i
return model.Image{}, err
}
defer logFile.Close()
rootfsPath := filepath.Join(artifactDir, "rootfs.ext4")
workSeedPath := filepath.Join(artifactDir, "work-seed.ext4")
kernelPath := params.KernelPath
if kernelPath == "" {
kernelPath = d.config.DefaultKernel
}
initrdPath := params.InitrdPath
if initrdPath == "" {
initrdPath = d.config.DefaultInitrd
}
modulesDir := params.ModulesDir
if modulesDir == "" {
modulesDir = d.config.DefaultModulesDir
}
if err := d.validateImageBuildPrereqs(ctx, baseRootfs, kernelPath, initrdPath, modulesDir, params.Size); err != nil {
stageDir, err := os.MkdirTemp(d.layout.ImagesDir, id+".build-")
if err != nil {
return model.Image{}, err
}
cleanupStage := true
defer func() {
if cleanupStage {
_ = os.RemoveAll(stageDir)
}
}()
rootfsPath := filepath.Join(stageDir, "rootfs.ext4")
workSeedPath := filepath.Join(stageDir, "work-seed.ext4")
kernelSource := firstNonEmpty(params.KernelPath, baseImage.KernelPath)
initrdSource := firstNonEmpty(params.InitrdPath, baseImage.InitrdPath)
modulesSource := firstNonEmpty(params.ModulesDir, baseImage.ModulesDir)
if err := d.validateImageBuildPrereqs(ctx, baseImage.RootfsPath, kernelSource, initrdSource, modulesSource, params.Size); err != nil {
return model.Image{}, err
}
kernelPath, initrdPath, modulesDir, err := stageManagedBootArtifacts(ctx, d.runner, stageDir, kernelSource, initrdSource, modulesSource)
if err != nil {
return model.Image{}, err
}
packages := imagepreset.DebianBasePackages()
metadataPackages := imageBuildMetadataPackages(params.Docker)
spec := imageBuildSpec{
ID: id,
Name: name,
BaseRootfs: baseRootfs,
SourceRootfs: baseImage.RootfsPath,
RootfsPath: rootfsPath,
BuildLog: logFile,
KernelPath: kernelPath,
InitrdPath: initrdPath,
ModulesDir: modulesDir,
PackagesPath: d.config.DefaultPackagesFile,
Packages: packages,
InstallDocker: params.Docker,
Size: params.Size,
}
op.stage("launch_builder", "build_log_path", buildLogPath, "artifact_dir", artifactDir)
op.stage("launch_builder", "build_log_path", buildLogPath, "artifact_dir", artifactDir, "from_image", baseImage.Name)
imageBuildStage(ctx, "launch_builder", "building rootfs from base image")
if err := d.runImageBuild(ctx, spec); err != nil {
_ = logFile.Sync()
_ = os.RemoveAll(artifactDir)
return model.Image{}, err
}
imageBuildStage(ctx, "prepare_work_seed", "building reusable work seed")
if err := system.BuildWorkSeedImage(ctx, d.runner, rootfsPath, workSeedPath); err != nil {
_ = logFile.Sync()
_ = os.RemoveAll(artifactDir)
return model.Image{}, err
}
imageBuildStage(ctx, "seed_ssh", "seeding runtime SSH access")
seededSSHPublicKeyFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath)
if err != nil {
_ = logFile.Sync()
_ = os.RemoveAll(artifactDir)
return model.Image{}, err
}
imageBuildStage(ctx, "write_metadata", "writing image metadata")
if err := writePackagesMetadata(rootfsPath, d.config.DefaultPackagesFile); err != nil {
if err := writePackagesMetadata(rootfsPath, metadataPackages); err != nil {
_ = logFile.Sync()
_ = os.RemoveAll(artifactDir)
return model.Image{}, err
}
op.stage("activate_artifacts", "artifact_dir", artifactDir)
if err := os.Rename(stageDir, artifactDir); err != nil {
return model.Image{}, err
}
cleanupStage = false
image = model.Image{
ID: id,
Name: name,
Managed: true,
ArtifactDir: artifactDir,
RootfsPath: rootfsPath,
WorkSeedPath: workSeedPath,
KernelPath: kernelPath,
InitrdPath: initrdPath,
ModulesDir: modulesDir,
PackagesPath: d.config.DefaultPackagesFile,
RootfsPath: filepath.Join(artifactDir, "rootfs.ext4"),
WorkSeedPath: filepath.Join(artifactDir, "work-seed.ext4"),
KernelPath: filepath.Join(artifactDir, "kernel"),
InitrdPath: stageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img"),
ModulesDir: stageOptionalArtifactPath(artifactDir, modulesDir, "modules"),
BuildSize: params.Size,
SeededSSHPublicKeyFingerprint: seededSSHPublicKeyFingerprint,
Docker: params.Docker,
@ -174,19 +179,12 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
}
kernelPath := strings.TrimSpace(params.KernelPath)
if kernelPath == "" {
kernelPath = d.config.DefaultKernel
return model.Image{}, fmt.Errorf("kernel path is required")
}
initrdPath := strings.TrimSpace(params.InitrdPath)
if initrdPath == "" {
initrdPath = d.config.DefaultInitrd
}
modulesDir := strings.TrimSpace(params.ModulesDir)
if modulesDir == "" {
modulesDir = d.config.DefaultModulesDir
}
packagesPath := strings.TrimSpace(params.PackagesPath)
if err := validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath); err != nil {
if err := validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir); err != nil {
return model.Image{}, err
}
@ -203,7 +201,6 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
image.KernelPath = kernelPath
image.InitrdPath = initrdPath
image.ModulesDir = modulesDir
image.PackagesPath = packagesPath
image.Docker = params.Docker
image.UpdatedAt = now
case errors.Is(lookupErr, sql.ErrNoRows):
@ -220,7 +217,6 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
KernelPath: kernelPath,
InitrdPath: initrdPath,
ModulesDir: modulesDir,
PackagesPath: packagesPath,
Docker: params.Docker,
CreatedAt: now,
UpdatedAt: now,
@ -255,7 +251,7 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
if image.Managed {
return model.Image{}, fmt.Errorf("image %s is already managed", image.Name)
}
if err := validateImagePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir, image.PackagesPath); err != nil {
if err := validateImagePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir); err != nil {
return model.Image{}, err
}
if strings.TrimSpace(d.layout.ImagesDir) == "" {
@ -313,6 +309,10 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
} else {
image.SeededSSHPublicKeyFingerprint = ""
}
_, initrdPath, modulesDir, err := stageManagedBootArtifacts(ctx, d.runner, stageDir, image.KernelPath, image.InitrdPath, image.ModulesDir)
if err != nil {
return model.Image{}, err
}
op.stage("activate_artifacts", "artifact_dir", artifactDir)
if err := os.Rename(stageDir, artifactDir); err != nil {
@ -326,6 +326,9 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
if workSeedPath != "" {
image.WorkSeedPath = filepath.Join(artifactDir, "work-seed.ext4")
}
image.KernelPath = filepath.Join(artifactDir, "kernel")
image.InitrdPath = stageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img")
image.ModulesDir = stageOptionalArtifactPath(artifactDir, modulesDir, "modules")
image.UpdatedAt = model.Now()
if err := d.store.UpsertImage(ctx, image); err != nil {
_ = os.RemoveAll(artifactDir)
@ -334,26 +337,23 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
return image, nil
}
func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath string) error {
func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir string) error {
checks := system.NewPreflight()
checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs <path>`)
checks.RequireFile(kernelPath, "kernel image", `pass --kernel <path> or set "default_kernel"`)
checks.RequireFile(kernelPath, "kernel image", `pass --kernel <path>`)
if workSeedPath != "" {
checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed <path> or rebuild the image with a work seed`)
}
if initrdPath != "" {
checks.RequireFile(initrdPath, "initrd image", `pass --initrd <path> or set "default_initrd"`)
checks.RequireFile(initrdPath, "initrd image", `pass --initrd <path>`)
}
if modulesDir != "" {
checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules <dir> or set "default_modules_dir"`)
}
if packagesPath != "" {
checks.RequireFile(packagesPath, "packages manifest", `pass --packages <path>`)
checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules <dir>`)
}
return checks.Err("image register failed")
}
func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir, packagesPath string) error {
func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir string) error {
checks := system.NewPreflight()
checks.RequireFile(rootfsPath, "rootfs image", `re-register the image with a valid rootfs`)
checks.RequireFile(kernelPath, "kernel image", `re-register the image with a valid kernel`)
@ -363,22 +363,15 @@ func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir, p
if modulesDir != "" {
checks.RequireDir(modulesDir, "kernel modules dir", `re-register the image with a valid modules dir`)
}
if packagesPath != "" {
checks.RequireFile(packagesPath, "packages manifest", `re-register the image with a valid packages manifest`)
}
return checks.Err("image promote failed")
}
func writePackagesMetadata(rootfsPath, packagesPath string) error {
if rootfsPath == "" || packagesPath == "" {
func writePackagesMetadata(rootfsPath string, packages []string) error {
if rootfsPath == "" || len(packages) == 0 {
return nil
}
lines, err := system.ReadNormalizedLines(packagesPath)
if err != nil {
return err
}
metadataPath := rootfsPath + ".packages.sha256"
return os.WriteFile(metadataPath, []byte(packagesHash(lines)+"\n"), 0o644)
return os.WriteFile(metadataPath, []byte(packagesHash(packages)+"\n"), 0o644)
}
func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image, error) {
@ -406,3 +399,52 @@ func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image,
}
return image, nil
}
func stageManagedBootArtifacts(ctx context.Context, runner system.CommandRunner, artifactDir, kernelSource, initrdSource, modulesSource string) (string, string, string, error) {
kernelPath := filepath.Join(artifactDir, "kernel")
if err := system.CopyFilePreferClone(kernelSource, kernelPath); err != nil {
return "", "", "", err
}
initrdPath := ""
if strings.TrimSpace(initrdSource) != "" {
initrdPath = filepath.Join(artifactDir, "initrd.img")
if err := system.CopyFilePreferClone(initrdSource, initrdPath); err != nil {
return "", "", "", err
}
}
modulesDir := ""
if strings.TrimSpace(modulesSource) != "" {
modulesDir = filepath.Join(artifactDir, "modules")
if err := os.MkdirAll(modulesDir, 0o755); err != nil {
return "", "", "", err
}
if err := system.CopyDirContents(ctx, runner, modulesSource, modulesDir, false); err != nil {
return "", "", "", err
}
}
return kernelPath, initrdPath, modulesDir, nil
}
func imageBuildMetadataPackages(docker bool) []string {
packages := imagepreset.DebianBasePackages()
if docker {
packages = append(packages, "#feature:docker")
}
return packages
}
func stageOptionalArtifactPath(artifactDir, stagedPath, name string) string {
if strings.TrimSpace(stagedPath) == "" {
return ""
}
return filepath.Join(artifactDir, name)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View file

@ -69,6 +69,7 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write vsock helper: %v", err)
}
t.Setenv("BANGER_VSOCK_AGENT_BIN", vsockHelper)
rootfsPath := filepath.Join(t.TempDir(), "rootfs.ext4")
kernelPath := filepath.Join(t.TempDir(), "vmlinux")
for _, path := range []string{rootfsPath, kernelPath} {
@ -109,7 +110,6 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) {
BridgeIP: model.DefaultBridgeIP,
DefaultDNS: model.DefaultDNS,
FirecrackerBin: firecrackerBin,
VSockAgentPath: vsockHelper,
StatsPollInterval: model.DefaultStatsPollInterval,
},
runner: runner,
@ -148,11 +148,10 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
baseRootfs := filepath.Join(t.TempDir(), "base.ext4")
kernelPath := filepath.Join(t.TempDir(), "vmlinux")
packagesPath := filepath.Join(t.TempDir(), "packages.apt")
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent")
for _, path := range []string{baseRootfs, kernelPath, packagesPath, sshKeyPath} {
for _, path := range []string{baseRootfs, kernelPath, sshKeyPath} {
if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
@ -160,6 +159,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write %s: %v", vsockHelper, err)
}
t.Setenv("BANGER_VSOCK_AGENT_BIN", vsockHelper)
if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write %s: %v", firecrackerBin, err)
}
@ -175,18 +175,26 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
if err != nil {
t.Fatalf("newDaemonLogger: %v", err)
}
baseImage := model.Image{
ID: "base-image",
Name: "base-image",
RootfsPath: baseRootfs,
KernelPath: kernelPath,
CreatedAt: model.Now(),
UpdatedAt: model.Now(),
}
if err := store.UpsertImage(ctx, baseImage); err != nil {
t.Fatalf("UpsertImage(base): %v", err)
}
d := &Daemon{
layout: paths.Layout{
StateDir: stateDir,
ImagesDir: imagesDir,
},
config: model.DaemonConfig{
RuntimeDir: t.TempDir(),
DefaultImageName: "default",
DefaultPackagesFile: packagesPath,
SSHKeyPath: sshKeyPath,
FirecrackerBin: firecrackerBin,
VSockAgentPath: vsockHelper,
DefaultImageName: "base-image",
SSHKeyPath: sshKeyPath,
FirecrackerBin: firecrackerBin,
},
store: store,
runner: runner,
@ -195,7 +203,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
if _, err := fmt.Fprintln(spec.BuildLog, "builder-stdout"); err != nil {
return err
}
if spec.BaseRootfs != baseRootfs || spec.KernelPath != kernelPath || spec.PackagesPath != packagesPath {
if spec.SourceRootfs != baseRootfs || spec.KernelPath == kernelPath || len(spec.Packages) == 0 {
t.Fatalf("unexpected image build spec: %+v", spec)
}
return errors.New("builder failed")
@ -204,7 +212,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) {
_, err = d.BuildImage(ctx, api.ImageBuildParams{
Name: "broken-image",
BaseRootfs: baseRootfs,
FromImage: baseImage.Name,
KernelPath: kernelPath,
})
if err == nil || !strings.Contains(err.Error(), "inspect ") {

View file

@ -5,7 +5,6 @@ import (
"strings"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
)
@ -50,16 +49,18 @@ func (d *Daemon) addNATPrereqs(ctx context.Context, checks *system.Preflight) {
}
func (d *Daemon) addBaseStartPrereqs(checks *system.Preflight, image model.Image) {
hint := paths.RuntimeBundleHint()
d.addBaseStartCommandPrereqs(checks)
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`)
if helper, err := d.vsockAgentBinary(); err == nil {
checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`)
} else {
checks.Addf("%v", err)
}
checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host")
checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle")
checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`)
checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid registered image")
checks.RequireFile(image.KernelPath, "kernel image", `re-register or rebuild the image with a valid kernel`)
if strings.TrimSpace(image.InitrdPath) != "" {
checks.RequireFile(image.InitrdPath, "initrd image", `set "default_initrd" or refresh the runtime bundle`)
checks.RequireFile(image.InitrdPath, "initrd image", `re-register or rebuild the image with a valid initrd`)
}
}
@ -70,30 +71,26 @@ func (d *Daemon) addBaseStartCommandPrereqs(checks *system.Preflight) {
}
func (d *Daemon) addImageBuildPrereqs(ctx context.Context, checks *system.Preflight, baseRootfs, kernelPath, initrdPath, modulesDir, sizeSpec string) {
hint := paths.RuntimeBundleHint()
for _, command := range []string{"sudo", "ip", "pgrep", "chown", "chmod", "kill"} {
checks.RequireCommand(command, toolHint(command))
}
for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} {
checks.RequireCommand(command, toolHint(command))
}
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint)
checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`)
checks.RequireFile(baseRootfs, "base rootfs image", `pass --base-rootfs or set "default_base_rootfs"`)
checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`)
checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`)
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`)
checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`)
if helper, err := d.vsockAgentBinary(); err == nil {
checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`)
} else {
checks.Addf("%v", err)
}
checks.RequireFile(baseRootfs, "base image rootfs", `pass --from-image with a valid registered image`)
checks.RequireFile(kernelPath, "kernel image", `pass --kernel or build from an image with a valid kernel`)
if strings.TrimSpace(initrdPath) != "" {
checks.RequireFile(initrdPath, "initrd image", `pass --initrd or set "default_initrd"`)
checks.RequireFile(initrdPath, "initrd image", `pass --initrd or build from an image with a valid initrd`)
}
if strings.TrimSpace(modulesDir) != "" {
checks.RequireDir(modulesDir, "modules directory", `pass --modules or set "default_modules_dir"`)
}
if strings.TrimSpace(d.config.DefaultPackagesFile) != "" {
if _, err := system.ReadNormalizedLines(d.config.DefaultPackagesFile); err != nil {
checks.Addf("package manifest at %s is invalid: %v", d.config.DefaultPackagesFile, err)
}
checks.RequireDir(modulesDir, "modules directory", `pass --modules or build from an image with a valid modules dir`)
}
if strings.TrimSpace(sizeSpec) != "" {
checks.RequireCommand("e2fsck", toolHint("e2fsck"))

View file

@ -0,0 +1,15 @@
package daemon
import (
"fmt"
"banger/internal/paths"
)
func (d *Daemon) vsockAgentBinary() (string, error) {
path, err := paths.CompanionBinaryPath("banger-vsock-agent")
if err != nil {
return "", fmt.Errorf("vsock agent helper not available: %w", err)
}
return path, nil
}

View file

@ -16,7 +16,7 @@ import (
"banger/internal/guest"
"banger/internal/guestconfig"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/namegen"
"banger/internal/system"
"banger/internal/vmdns"
"banger/internal/vsockagent"
@ -998,13 +998,20 @@ func (d *Daemon) createTap(ctx context.Context, tap string) error {
func (d *Daemon) firecrackerBinary() (string, error) {
if d.config.FirecrackerBin == "" {
return "", fmt.Errorf("firecracker binary not configured; %s", paths.RuntimeBundleHint())
return "", fmt.Errorf("firecracker binary not configured; install firecracker or set firecracker_bin")
}
path := d.config.FirecrackerBin
if !exists(path) {
return "", fmt.Errorf("firecracker binary not found at %s; %s", path, paths.RuntimeBundleHint())
if strings.ContainsRune(path, os.PathSeparator) {
if !exists(path) {
return "", fmt.Errorf("firecracker binary not found at %s; install firecracker or set firecracker_bin", path)
}
return path, nil
}
return path, nil
resolved, err := system.LookupExecutable(path)
if err != nil {
return "", fmt.Errorf("firecracker binary %q not found in PATH; install firecracker or set firecracker_bin", path)
}
return resolved, nil
}
func (d *Daemon) ensureSocketAccess(ctx context.Context, socketPath, label string) error {
@ -1190,14 +1197,9 @@ func (d *Daemon) killVMProcess(ctx context.Context, pid int) error {
}
func (d *Daemon) generateName(ctx context.Context) (string, error) {
if exists(d.config.NamegenPath) {
out, err := d.runner.Run(ctx, d.config.NamegenPath)
if err == nil {
name := strings.TrimSpace(string(out))
if name != "" {
return name, nil
}
}
_ = ctx
if name := strings.TrimSpace(namegen.Generate()); name != "" {
return name, nil
}
return "vm-" + strconv.FormatInt(time.Now().Unix(), 10), nil
}

View file

@ -4,13 +4,14 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
@ -183,6 +184,7 @@ func TestRebuildDNSIncludesOnlyLiveRunningVMs(t *testing.T) {
server, err := vmdns.New("127.0.0.1:0", nil)
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("vmdns.New: %v", err)
}
t.Cleanup(func() {
@ -274,6 +276,7 @@ func TestHealthVMReturnsHealthyForRunningGuest(t *testing.T) {
vsockSock := filepath.Join(t.TempDir(), "fc.vsock")
listener, err := net.Listen("unix", vsockSock)
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("listen vsock: %v", err)
}
t.Cleanup(func() {
@ -367,6 +370,7 @@ func TestPingVMAliasReturnsAliveForHealthyVM(t *testing.T) {
vsockSock := filepath.Join(t.TempDir(), "fc.vsock")
listener, err := net.Listen("unix", vsockSock)
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("listen vsock: %v", err)
}
t.Cleanup(func() {
@ -441,32 +445,17 @@ func TestPortsVMReturnsEnrichedPortsAndWebSchemes(t *testing.T) {
_ = fake.Wait()
})
webServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
webAddr := startHTTPServerOnTCP4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(webServer.Close)
webAddr, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(webServer.URL, "http://"))
if err != nil {
t.Fatalf("ResolveTCPAddr: %v", err)
}
tlsServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tlsAddr := startHTTPSServerOnTCP4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
}))
tlsListener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen tls: %v", err)
}
tlsServer.Listener = tlsListener
tlsServer.StartTLS()
t.Cleanup(tlsServer.Close)
tlsAddr, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(tlsServer.URL, "https://"))
if err != nil {
t.Fatalf("ResolveTCPAddr(tls): %v", err)
}
vsockSock := filepath.Join(t.TempDir(), "fc.vsock")
listener, err := net.Listen("unix", vsockSock)
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("listen vsock: %v", err)
}
t.Cleanup(func() {
@ -1263,6 +1252,7 @@ func startFakeFirecrackerAPI(t *testing.T, apiSock string) {
}
listener, err := net.Listen("unix", apiSock)
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("listen unix %s: %v", apiSock, err)
}
mux := http.NewServeMux()
@ -1283,6 +1273,72 @@ func startFakeFirecrackerAPI(t *testing.T, apiSock string) {
})
}
func skipIfSocketRestricted(t *testing.T, err error) {
t.Helper()
if err == nil {
return
}
if strings.Contains(strings.ToLower(err.Error()), "operation not permitted") {
t.Skipf("socket creation is restricted in this environment: %v", err)
}
}
func startHTTPServerOnTCP4(t *testing.T, handler http.Handler) *net.TCPAddr {
t.Helper()
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("listen http: %v", err)
}
server := &http.Server{Handler: handler}
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
_ = server.Close()
})
return listener.Addr().(*net.TCPAddr)
}
func startHTTPSServerOnTCP4(t *testing.T, handler http.Handler) *net.TCPAddr {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatalf("X509KeyPair: %v", err)
}
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("listen https: %v", err)
}
server := &http.Server{Handler: handler}
go func() {
_ = server.Serve(tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{cert}}))
}()
t.Cleanup(func() {
_ = server.Close()
})
return listener.Addr().(*net.TCPAddr)
}
type processKillingRunner struct {
*scriptedRunner
proc *exec.Cmd