Serve daemon-managed .vm names directly from bangerd on 127.0.0.1:42069 instead of shelling out to mapdns. This keeps DNS state tied to VM lifecycle and lets the daemon rebuild records from running VMs after startup or reconcile. Add a small in-process authoritative DNS server, register and remove records from the VM start/stop/delete paths, and show the listener in daemon status. Remove the mapdns config and preflight surface, stop helper-flow DNS publishing in customize.sh and interactive.sh, drop dns.sh from the runtime bundle, and update docs/tests for the new local-resolver integration model. Validated with GOCACHE=/tmp/banger-gocache go test ./..., GOCACHE=/tmp/banger-gocache make build, and bash -n customize.sh interactive.sh.
420 lines
11 KiB
Go
420 lines
11 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/model"
|
|
"banger/internal/rpc"
|
|
"banger/internal/store"
|
|
)
|
|
|
|
func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir)
|
|
db := openDefaultImageStore(t, dir)
|
|
|
|
d := &Daemon{
|
|
config: model.DaemonConfig{
|
|
DefaultImageName: "default",
|
|
DefaultRootfs: rootfs,
|
|
DefaultKernel: kernel,
|
|
},
|
|
store: db,
|
|
}
|
|
|
|
if err := d.ensureDefaultImage(context.Background()); err != nil {
|
|
t.Fatalf("ensureDefaultImage: %v", err)
|
|
}
|
|
|
|
image, err := db.GetImageByName(context.Background(), "default")
|
|
if err != nil {
|
|
t.Fatalf("GetImageByName: %v", err)
|
|
}
|
|
if image.RootfsPath != rootfs {
|
|
t.Fatalf("RootfsPath = %q, want %q", image.RootfsPath, rootfs)
|
|
}
|
|
if image.KernelPath != kernel {
|
|
t.Fatalf("KernelPath = %q, want %q", image.KernelPath, kernel)
|
|
}
|
|
if image.Managed {
|
|
t.Fatal("default image should be unmanaged")
|
|
}
|
|
}
|
|
|
|
func TestEnsureDefaultImageLeavesCurrentUnmanagedDefaultUntouched(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir)
|
|
db := openDefaultImageStore(t, dir)
|
|
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
|
|
image := model.Image{
|
|
ID: "default-id",
|
|
Name: "default",
|
|
Managed: false,
|
|
RootfsPath: rootfs,
|
|
KernelPath: kernel,
|
|
InitrdPath: initrd,
|
|
ModulesDir: modulesDir,
|
|
PackagesPath: packages,
|
|
Docker: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := db.UpsertImage(context.Background(), image); err != nil {
|
|
t.Fatalf("UpsertImage: %v", err)
|
|
}
|
|
|
|
d := &Daemon{
|
|
config: model.DaemonConfig{
|
|
DefaultImageName: "default",
|
|
DefaultRootfs: rootfs,
|
|
DefaultKernel: kernel,
|
|
DefaultInitrd: initrd,
|
|
DefaultModulesDir: modulesDir,
|
|
DefaultPackagesFile: packages,
|
|
},
|
|
store: db,
|
|
}
|
|
|
|
if err := d.ensureDefaultImage(context.Background()); err != nil {
|
|
t.Fatalf("ensureDefaultImage: %v", err)
|
|
}
|
|
|
|
got, err := db.GetImageByName(context.Background(), "default")
|
|
if err != nil {
|
|
t.Fatalf("GetImageByName: %v", err)
|
|
}
|
|
if got.ID != image.ID {
|
|
t.Fatalf("ID = %q, want %q", got.ID, image.ID)
|
|
}
|
|
if !got.UpdatedAt.Equal(image.UpdatedAt) {
|
|
t.Fatalf("UpdatedAt = %s, want unchanged %s", got.UpdatedAt, image.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
func TestEnsureDefaultImageReconcilesStaleUnmanagedDefaultInPlace(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir)
|
|
db := openDefaultImageStore(t, dir)
|
|
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
|
|
stale := model.Image{
|
|
ID: "default-id",
|
|
Name: "default",
|
|
Managed: false,
|
|
RootfsPath: "/home/thales/projects/personal/banger/rootfs-docker.ext4",
|
|
KernelPath: "/home/thales/projects/personal/banger/wtf/root/boot/vmlinux-6.8.0-94-generic",
|
|
InitrdPath: "/home/thales/projects/personal/banger/wtf/root/boot/initrd.img-6.8.0-94-generic",
|
|
ModulesDir: "/home/thales/projects/personal/banger/wtf/root/lib/modules/6.8.0-94-generic",
|
|
PackagesPath: "/home/thales/projects/personal/banger/packages.apt",
|
|
Docker: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := db.UpsertImage(context.Background(), stale); err != nil {
|
|
t.Fatalf("UpsertImage: %v", err)
|
|
}
|
|
vm := testVM("uses-default", stale.ID, "172.16.0.25")
|
|
if err := db.UpsertVM(context.Background(), vm); err != nil {
|
|
t.Fatalf("UpsertVM: %v", err)
|
|
}
|
|
|
|
d := &Daemon{
|
|
config: model.DaemonConfig{
|
|
DefaultImageName: "default",
|
|
DefaultRootfs: rootfs,
|
|
DefaultKernel: kernel,
|
|
DefaultInitrd: initrd,
|
|
DefaultModulesDir: modulesDir,
|
|
DefaultPackagesFile: packages,
|
|
},
|
|
store: db,
|
|
}
|
|
|
|
if err := d.ensureDefaultImage(context.Background()); err != nil {
|
|
t.Fatalf("ensureDefaultImage: %v", err)
|
|
}
|
|
|
|
got, err := db.GetImageByName(context.Background(), "default")
|
|
if err != nil {
|
|
t.Fatalf("GetImageByName: %v", err)
|
|
}
|
|
if got.ID != stale.ID {
|
|
t.Fatalf("ID = %q, want preserved %q", got.ID, stale.ID)
|
|
}
|
|
if !got.CreatedAt.Equal(stale.CreatedAt) {
|
|
t.Fatalf("CreatedAt = %s, want preserved %s", got.CreatedAt, stale.CreatedAt)
|
|
}
|
|
if got.RootfsPath != rootfs || got.KernelPath != kernel || got.InitrdPath != initrd || got.ModulesDir != modulesDir || got.PackagesPath != packages {
|
|
t.Fatalf("stale default not reconciled: %+v", got)
|
|
}
|
|
if !got.UpdatedAt.After(stale.UpdatedAt) {
|
|
t.Fatalf("UpdatedAt = %s, want newer than %s", got.UpdatedAt, stale.UpdatedAt)
|
|
}
|
|
gotVM, err := db.GetVMByID(context.Background(), vm.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetVMByID: %v", err)
|
|
}
|
|
if gotVM.ImageID != stale.ID {
|
|
t.Fatalf("VM image ID = %q, want preserved %q", gotVM.ImageID, stale.ID)
|
|
}
|
|
}
|
|
|
|
func TestEnsureDefaultImageLeavesManagedDefaultUntouched(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir)
|
|
db := openDefaultImageStore(t, dir)
|
|
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
|
|
managed := model.Image{
|
|
ID: "managed-default",
|
|
Name: "default",
|
|
Managed: true,
|
|
RootfsPath: "/managed/rootfs.ext4",
|
|
KernelPath: "/managed/vmlinux",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := db.UpsertImage(context.Background(), managed); err != nil {
|
|
t.Fatalf("UpsertImage: %v", err)
|
|
}
|
|
|
|
d := &Daemon{
|
|
config: model.DaemonConfig{
|
|
DefaultImageName: "default",
|
|
DefaultRootfs: rootfs,
|
|
DefaultKernel: kernel,
|
|
},
|
|
store: db,
|
|
}
|
|
|
|
if err := d.ensureDefaultImage(context.Background()); err != nil {
|
|
t.Fatalf("ensureDefaultImage: %v", err)
|
|
}
|
|
|
|
got, err := db.GetImageByName(context.Background(), "default")
|
|
if err != nil {
|
|
t.Fatalf("GetImageByName: %v", err)
|
|
}
|
|
if got.RootfsPath != managed.RootfsPath || got.KernelPath != managed.KernelPath {
|
|
t.Fatalf("managed default was rewritten: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestEnsureDefaultImageSkipsRewriteWhenCurrentArtifactsMissing(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := openDefaultImageStore(t, dir)
|
|
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
|
|
stale := model.Image{
|
|
ID: "default-id",
|
|
Name: "default",
|
|
Managed: false,
|
|
RootfsPath: "/old/rootfs.ext4",
|
|
KernelPath: "/old/vmlinux",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := db.UpsertImage(context.Background(), stale); err != nil {
|
|
t.Fatalf("UpsertImage: %v", err)
|
|
}
|
|
|
|
d := &Daemon{
|
|
config: model.DaemonConfig{
|
|
DefaultImageName: "default",
|
|
DefaultRootfs: filepath.Join(dir, "missing-rootfs.ext4"),
|
|
DefaultKernel: filepath.Join(dir, "missing-vmlinux"),
|
|
},
|
|
store: db,
|
|
}
|
|
|
|
if err := d.ensureDefaultImage(context.Background()); err != nil {
|
|
t.Fatalf("ensureDefaultImage: %v", err)
|
|
}
|
|
|
|
got, err := db.GetImageByName(context.Background(), "default")
|
|
if err != nil {
|
|
t.Fatalf("GetImageByName: %v", err)
|
|
}
|
|
if got.RootfsPath != stale.RootfsPath || got.KernelPath != stale.KernelPath {
|
|
t.Fatalf("default image should have stayed stale when no current artifacts exist: %+v", got)
|
|
}
|
|
}
|
|
|
|
func openDefaultImageStore(t *testing.T, dir string) *store.Store {
|
|
t.Helper()
|
|
db, err := store.Open(filepath.Join(dir, "state.db"))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = db.Close()
|
|
})
|
|
return db
|
|
}
|
|
|
|
func writeDefaultImageArtifacts(t *testing.T, dir string) (rootfs, kernel, initrd, modulesDir, packages string) {
|
|
t.Helper()
|
|
rootfs = filepath.Join(dir, "rootfs-docker.ext4")
|
|
kernel = filepath.Join(dir, "vmlinux")
|
|
initrd = filepath.Join(dir, "initrd.img")
|
|
modulesDir = filepath.Join(dir, "modules")
|
|
packages = filepath.Join(dir, "packages.apt")
|
|
files := []string{
|
|
rootfs,
|
|
kernel,
|
|
initrd,
|
|
packages,
|
|
filepath.Join(modulesDir, "modules.dep"),
|
|
}
|
|
for _, path := range files {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
|
}
|
|
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
return rootfs, kernel, initrd, modulesDir, packages
|
|
}
|
|
|
|
func 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
|
|
}
|