Stop long-running daemon operations from running under context.Background by\nthreading a request-scoped context from handleConn into dispatch. The daemon\nnow cancels in-flight handlers when the client socket goes away, and the RPC\nclient closes its Unix connection when the caller context is canceled so that\ninterrupts actually reach the daemon boundary.\n\nAdd regression coverage for both sides of the path: canceled dispatch calls,\nclient disconnects during handleConn, watcher EOF cancellation, and context\ncancellation without an RPC deadline.\n\nValidated with GOCACHE=/tmp/banger-gocache go test ./... and\nGOCACHE=/tmp/banger-gocache make build.
442 lines
12 KiB
Go
442 lines
12 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 TestSetDNSUsesConfiguredMapDNSDataFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataFile := filepath.Join(t.TempDir(), "mapdns", "records.json")
|
|
runner := &scriptedRunner{
|
|
t: t,
|
|
steps: []runnerStep{
|
|
{
|
|
call: runnerCall{
|
|
name: "custom-mapdns",
|
|
args: []string{"set", "--data-file", dataFile, "devbox.vm", "172.16.0.8"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
d := &Daemon{
|
|
runner: runner,
|
|
config: model.DaemonConfig{
|
|
MapDNSBin: "custom-mapdns",
|
|
MapDNSDataFile: dataFile,
|
|
},
|
|
}
|
|
|
|
if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil {
|
|
t.Fatalf("setDNS: %v", err)
|
|
}
|
|
runner.assertExhausted()
|
|
}
|
|
|
|
func TestSetDNSUsesMapDNSDefaultsWhenDataFileUnset(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &scriptedRunner{
|
|
t: t,
|
|
steps: []runnerStep{
|
|
{
|
|
call: runnerCall{
|
|
name: "mapdns",
|
|
args: []string{"set", "devbox.vm", "172.16.0.8"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
d := &Daemon{
|
|
runner: runner,
|
|
config: model.DaemonConfig{},
|
|
}
|
|
|
|
if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil {
|
|
t.Fatalf("setDNS: %v", err)
|
|
}
|
|
runner.assertExhausted()
|
|
}
|
|
|
|
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
|
|
}
|