banger/internal/daemon/vm_test.go
Thales Maciel 59e48e830b
daemon: split owner daemon from root helper
Move the supported systemd path to two services: an owner-user bangerd for
orchestration and a narrow root helper for bridge/tap, NAT/resolver, dm/loop,
and Firecracker ownership. This removes repeated sudo from daily vm and image
flows without leaving the general daemon running as root.

Add install metadata, system install/status/restart/uninstall commands, and a
system-owned runtime layout. Keep user SSH/config material in the owner home,
lock file_sync to the owner home, and move daemon known_hosts handling out of
the old root-owned control path.

Route privileged lifecycle steps through typed privilegedOps calls, harden the
two systemd units, and rewrite smoke plus docs around the supported service
model.

Verified with make build, make test, make lint, and make smoke on the
supported systemd host path.
2026-04-26 12:43:17 -03:00

2320 lines
67 KiB
Go

package daemon
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"banger/internal/api"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/store"
"banger/internal/vmdns"
"banger/internal/vsockagent"
)
func TestFindVMPrefixResolution(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
d := &Daemon{store: db}
wireServices(d)
for _, vm := range []model.VMRecord{
testVM("alpha", "image-alpha", "172.16.0.2"),
testVM("alpine", "image-alpha", "172.16.0.3"),
testVM("bravo", "image-alpha", "172.16.0.4"),
} {
upsertDaemonVM(t, ctx, db, vm)
}
vm, err := d.FindVM(ctx, "alpha")
if err != nil || vm.Name != "alpha" {
t.Fatalf("FindVM(alpha) = %+v, %v", vm, err)
}
vm, err = d.FindVM(ctx, "br")
if err != nil || vm.Name != "bravo" {
t.Fatalf("FindVM(br) = %+v, %v", vm, err)
}
_, err = d.FindVM(ctx, "al")
if err == nil || !strings.Contains(err.Error(), "multiple VMs match") {
t.Fatalf("FindVM(al) error = %v, want ambiguity", err)
}
_, err = d.FindVM(ctx, "missing")
if err == nil || !strings.Contains(err.Error(), `vm "missing" not found`) {
t.Fatalf("FindVM(missing) error = %v, want not-found", err)
}
}
func TestFindImagePrefixResolution(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
d := &Daemon{store: db}
wireServices(d)
for _, image := range []model.Image{
testImage("base"),
testImage("basic"),
testImage("docker"),
} {
if err := db.UpsertImage(ctx, image); err != nil {
t.Fatalf("UpsertImage(%s): %v", image.Name, err)
}
}
image, err := d.FindImage(ctx, "base")
if err != nil || image.Name != "base" {
t.Fatalf("FindImage(base) = %+v, %v", image, err)
}
image, err = d.FindImage(ctx, "dock")
if err != nil || image.Name != "docker" {
t.Fatalf("FindImage(dock) = %+v, %v", image, err)
}
_, err = d.FindImage(ctx, "ba")
if err == nil || !strings.Contains(err.Error(), "multiple images match") {
t.Fatalf("FindImage(ba) error = %v, want ambiguity", err)
}
_, err = d.FindImage(ctx, "missing")
if err == nil || !strings.Contains(err.Error(), `image "missing" not found`) {
t.Fatalf("FindImage(missing) error = %v, want not-found", err)
}
}
func TestReconcileStopsStaleRunningVMAndClearsRuntimeHandles(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "fc.sock")
if err := os.WriteFile(apiSock, []byte{}, 0o644); err != nil {
t.Fatalf("WriteFile(api sock): %v", err)
}
vmDir := t.TempDir()
vm := testVM("stale", "image-stale", "172.16.0.9")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.APISockPath = apiSock
vm.Runtime.VMDir = vmDir
vm.Runtime.DNSName = ""
upsertDaemonVM(t, ctx, db, vm)
// Simulate the prior daemon crashing while this VM was running:
// the handles.json scratch file survives and names a stale PID +
// DM snapshot. Reconcile should discover the PID is gone, tear
// the kernel state down via the runner, and clear the scratch.
stale := model.VMHandles{
PID: 999999,
BaseLoop: "/dev/loop10",
COWLoop: "/dev/loop11",
DMName: "fc-rootfs-stale",
DMDev: "/dev/mapper/fc-rootfs-stale",
}
if err := writeHandlesFile(vmDir, stale); err != nil {
t.Fatalf("writeHandlesFile: %v", err)
}
runner := &scriptedRunner{
t: t,
steps: []runnerStep{
// First pgrep: rediscoverHandles tries to verify the PID.
{call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, err: errors.New("exit status 1")},
// Second pgrep: cleanupRuntime asks again before killing.
{call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, err: errors.New("exit status 1")},
sudoStep("", nil, "dmsetup", "remove", "fc-rootfs-stale"),
sudoStep("", nil, "losetup", "-d", "/dev/loop11"),
sudoStep("", nil, "losetup", "-d", "/dev/loop10"),
},
}
d := &Daemon{store: db, runner: runner}
wireServices(d)
if err := d.reconcile(ctx); err != nil {
t.Fatalf("reconcile: %v", err)
}
runner.assertExhausted()
got, err := db.GetVM(ctx, vm.ID)
if err != nil {
t.Fatalf("GetVM: %v", err)
}
if got.State != model.VMStateStopped || got.Runtime.State != model.VMStateStopped {
t.Fatalf("vm state after reconcile = %s/%s, want stopped", got.State, got.Runtime.State)
}
// The scratch file must be gone — stopped VMs don't carry handles.
if _, err := os.Stat(handlesFilePath(vmDir)); !os.IsNotExist(err) {
t.Fatalf("handles.json still present after reconcile: %v", err)
}
// And the in-memory cache must be empty.
if h, ok := d.vm.handles.get(vm.ID); ok && !h.IsZero() {
t.Fatalf("handle cache not cleared after reconcile: %+v", h)
}
}
func TestReconcileWithCorruptHandlesFileFallsBackToPersistedRuntimeTeardownState(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "fc.sock")
if err := os.WriteFile(apiSock, []byte{}, 0o644); err != nil {
t.Fatalf("WriteFile(api sock): %v", err)
}
vmDir := t.TempDir()
vm := testVM("corrupt", "image-corrupt", "172.16.0.10")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.APISockPath = apiSock
vm.Runtime.VMDir = vmDir
vm.Runtime.DNSName = ""
vm.Runtime.TapDevice = "tap-fc-corrupt"
vm.Runtime.BaseLoop = "/dev/loop20"
vm.Runtime.COWLoop = "/dev/loop21"
vm.Runtime.DMName = "fc-rootfs-corrupt"
vm.Runtime.DMDev = "/dev/mapper/fc-rootfs-corrupt"
upsertDaemonVM(t, ctx, db, vm)
if err := os.WriteFile(handlesFilePath(vmDir), []byte("{not json"), 0o600); err != nil {
t.Fatalf("WriteFile(handles.json): %v", err)
}
runner := &scriptedRunner{
t: t,
steps: []runnerStep{
{call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, err: errors.New("exit status 1")},
sudoStep("", nil, "dmsetup", "remove", "fc-rootfs-corrupt"),
sudoStep("", nil, "losetup", "-d", "/dev/loop21"),
sudoStep("", nil, "losetup", "-d", "/dev/loop20"),
sudoStep("", nil, "ip", "link", "del", "tap-fc-corrupt"),
},
}
d := &Daemon{store: db, runner: runner}
wireServices(d)
if err := d.reconcile(ctx); err != nil {
t.Fatalf("reconcile: %v", err)
}
runner.assertExhausted()
got, err := db.GetVM(ctx, vm.ID)
if err != nil {
t.Fatalf("GetVM: %v", err)
}
if got.State != model.VMStateStopped || got.Runtime.State != model.VMStateStopped {
t.Fatalf("vm state after reconcile = %s/%s, want stopped", got.State, got.Runtime.State)
}
if got.Runtime.TapDevice != "" || got.Runtime.BaseLoop != "" || got.Runtime.COWLoop != "" || got.Runtime.DMName != "" || got.Runtime.DMDev != "" {
t.Fatalf("runtime teardown state not cleared after reconcile: %+v", got.Runtime)
}
if _, err := os.Stat(handlesFilePath(vmDir)); !os.IsNotExist(err) {
t.Fatalf("handles.json still present after reconcile: %v", err)
}
}
func TestRebuildDNSIncludesOnlyLiveRunningVMs(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
liveSock := filepath.Join(t.TempDir(), "live.sock")
liveCmd := startFakeFirecrackerProcess(t, liveSock)
t.Cleanup(func() {
_ = liveCmd.Process.Kill()
_ = liveCmd.Wait()
})
live := testVM("live", "image-live", "172.16.0.21")
live.State = model.VMStateRunning
live.Runtime.State = model.VMStateRunning
live.Runtime.APISockPath = liveSock
stale := testVM("stale", "image-stale", "172.16.0.22")
stale.State = model.VMStateRunning
stale.Runtime.State = model.VMStateRunning
stale.Runtime.APISockPath = filepath.Join(t.TempDir(), "stale.sock")
stopped := testVM("stopped", "image-stopped", "172.16.0.23")
for _, vm := range []model.VMRecord{live, stale, stopped} {
upsertDaemonVM(t, ctx, db, vm)
}
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() {
if err := server.Close(); err != nil {
t.Fatalf("server.Close: %v", err)
}
})
d := &Daemon{store: db, net: &HostNetwork{vmDNS: server}}
wireServices(d)
// rebuildDNS reads the alive check from the handle cache. Seed
// the live VM with its real PID; leave the stale entry with a PID
// that definitely isn't running (999999 ≫ max PID on most hosts).
d.vm.setVMHandlesInMemory(live.ID, model.VMHandles{PID: liveCmd.Process.Pid})
d.vm.setVMHandlesInMemory(stale.ID, model.VMHandles{PID: 999999})
if err := d.vm.rebuildDNS(ctx); err != nil {
t.Fatalf("rebuildDNS: %v", err)
}
if _, ok := server.Lookup("live.vm"); !ok {
t.Fatal("live.vm missing after rebuildDNS")
}
if _, ok := server.Lookup("stale.vm"); ok {
t.Fatal("stale.vm should not be published")
}
if _, ok := server.Lookup("stopped.vm"); ok {
t.Fatal("stopped.vm should not be published")
}
}
func TestSetVMRejectsStoppedOnlyChangesForRunningVM(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "running.sock")
cmd := startFakeFirecrackerProcess(t, apiSock)
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
vm := testVM("running", "image-run", "172.16.0.10")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.APISockPath = apiSock
upsertDaemonVM(t, ctx, db, vm)
d := &Daemon{store: db}
wireServices(d)
d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: cmd.Process.Pid})
tests := []struct {
name string
params api.VMSetParams
want string
}{
{
name: "vcpu",
params: api.VMSetParams{IDOrName: vm.ID, VCPUCount: ptr(4)},
want: "vcpu changes require the VM to be stopped",
},
{
name: "memory",
params: api.VMSetParams{IDOrName: vm.ID, MemoryMiB: ptr(2048)},
want: "memory changes require the VM to be stopped",
},
{
name: "disk",
params: api.VMSetParams{IDOrName: vm.ID, WorkDiskSize: "16G"},
want: "disk changes require the VM to be stopped",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := d.vm.SetVM(ctx, tt.params)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("SetVM(%s) error = %v, want %q", tt.name, err, tt.want)
}
})
}
}
func TestHealthVMReturnsHealthyForRunningGuest(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "fc.sock")
fake := startFakeFirecrackerProcess(t, apiSock)
t.Cleanup(func() {
_ = fake.Process.Kill()
_ = fake.Wait()
})
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() {
_ = listener.Close()
_ = os.Remove(vsockSock)
})
serverDone := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
serverDone <- err
return
}
defer conn.Close()
buf := make([]byte, 128)
n, err := conn.Read(buf)
if err != nil {
serverDone <- err
return
}
if got := string(buf[:n]); got != "CONNECT 42070\n" {
serverDone <- fmt.Errorf("unexpected connect message %q", got)
return
}
if _, err := conn.Write([]byte("OK 1\n")); err != nil {
serverDone <- err
return
}
reqBuf := make([]byte, 0, 512)
reqBuf = append(reqBuf, buf[:0]...)
for {
n, err = conn.Read(buf)
if err != nil {
serverDone <- err
return
}
reqBuf = append(reqBuf, buf[:n]...)
if strings.Contains(string(reqBuf), "\r\n\r\n") {
break
}
}
if got := string(reqBuf); !strings.Contains(got, "GET /healthz HTTP/1.1\r\n") {
serverDone <- fmt.Errorf("unexpected health payload %q", got)
return
}
_, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
serverDone <- err
}()
vm := testVM("alive", "image-alive", "172.16.0.41")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.APISockPath = apiSock
vm.Runtime.VSockPath = vsockSock
vm.Runtime.VSockCID = 10041
upsertDaemonVM(t, ctx, db, vm)
handlePID := fake.Process.Pid
runner := &scriptedRunner{
t: t,
steps: []runnerStep{
sudoStep("", nil, "chmod", "600", vsockSock),
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
},
}
d := &Daemon{store: db, runner: runner}
wireServices(d)
d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: handlePID})
result, err := d.stats.HealthVM(ctx, vm.Name)
if err != nil {
t.Fatalf("HealthVM: %v", err)
}
if !result.Healthy || result.Name != vm.Name {
t.Fatalf("HealthVM result = %+v, want healthy %s", result, vm.Name)
}
runner.assertExhausted()
if err := <-serverDone; err != nil {
t.Fatalf("server: %v", err)
}
}
func TestPingVMAliasReturnsAliveForHealthyVM(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "fc.sock")
fake := startFakeFirecrackerProcess(t, apiSock)
t.Cleanup(func() {
_ = fake.Process.Kill()
_ = fake.Wait()
})
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() {
_ = listener.Close()
_ = os.Remove(vsockSock)
})
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
buf := make([]byte, 512)
_, _ = conn.Read(buf)
_, _ = conn.Write([]byte("OK 1\n"))
_, _ = conn.Read(buf)
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
}()
vm := testVM("healthy-ping", "image-healthy", "172.16.0.42")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.APISockPath = apiSock
vm.Runtime.VSockPath = vsockSock
vm.Runtime.VSockCID = 10042
upsertDaemonVM(t, ctx, db, vm)
runner := &scriptedRunner{
t: t,
steps: []runnerStep{
sudoStep("", nil, "chmod", "600", vsockSock),
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
},
}
d := &Daemon{store: db, runner: runner}
wireServices(d)
d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: fake.Process.Pid})
result, err := d.stats.PingVM(ctx, vm.Name)
if err != nil {
t.Fatalf("PingVM: %v", err)
}
if !result.Alive {
t.Fatalf("PingVM result = %+v, want alive", result)
}
}
func TestWaitForGuestVSockAgentRetriesUntilHealthy(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(t.TempDir(), "fc.vsock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("listen vsock: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
_ = os.Remove(socketPath)
})
serverDone := make(chan error, 1)
go func() {
for attempt := 0; attempt < 2; attempt++ {
conn, err := listener.Accept()
if err != nil {
serverDone <- err
return
}
buf := make([]byte, 512)
n, err := conn.Read(buf)
if err != nil {
_ = conn.Close()
serverDone <- err
return
}
if got := string(buf[:n]); got != "CONNECT 42070\n" {
_ = conn.Close()
serverDone <- fmt.Errorf("unexpected connect message %q", got)
return
}
if _, err := conn.Write([]byte("OK 1\n")); err != nil {
_ = conn.Close()
serverDone <- err
return
}
if attempt == 0 {
_ = conn.Close()
continue
}
reqBuf := make([]byte, 0, 512)
for {
n, err = conn.Read(buf)
if err != nil {
_ = conn.Close()
serverDone <- err
return
}
reqBuf = append(reqBuf, buf[:n]...)
if strings.Contains(string(reqBuf), "\r\n\r\n") {
break
}
}
if got := string(reqBuf); !strings.Contains(got, "GET /healthz HTTP/1.1\r\n") {
_ = conn.Close()
serverDone <- fmt.Errorf("unexpected health payload %q", got)
return
}
_, err = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"))
_ = conn.Close()
serverDone <- err
return
}
serverDone <- errors.New("health probe did not retry")
}()
n := &HostNetwork{}
if err := n.waitForGuestVSockAgent(context.Background(), socketPath, time.Second); err != nil {
t.Fatalf("waitForGuestVSockAgent: %v", err)
}
if err := <-serverDone; err != nil {
t.Fatalf("server: %v", err)
}
}
func TestHealthVMReturnsFalseForStoppedVM(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
vm := testVM("stopped-ping", "image-stopped", "172.16.0.42")
upsertDaemonVM(t, ctx, db, vm)
d := &Daemon{store: db}
wireServices(d)
result, err := d.stats.HealthVM(ctx, vm.Name)
if err != nil {
t.Fatalf("HealthVM: %v", err)
}
if result.Healthy {
t.Fatalf("HealthVM result = %+v, want not healthy", result)
}
}
func TestPortsVMReturnsEnrichedPortsAndWebSchemes(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "fc.sock")
fake := startFakeFirecrackerProcess(t, apiSock)
t.Cleanup(func() {
_ = fake.Process.Kill()
_ = fake.Wait()
})
webAddr := startHTTPServerOnTCP4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
tlsAddr := startHTTPSServerOnTCP4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
}))
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() {
_ = listener.Close()
_ = os.Remove(vsockSock)
})
serverDone := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
serverDone <- err
return
}
defer conn.Close()
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
serverDone <- err
return
}
if got := string(buf[:n]); got != "CONNECT 42070\n" {
serverDone <- fmt.Errorf("unexpected connect message %q", got)
return
}
if _, err := conn.Write([]byte("OK 1\n")); err != nil {
serverDone <- err
return
}
reqBuf := make([]byte, 0, 1024)
for {
n, err = conn.Read(buf)
if err != nil {
serverDone <- err
return
}
reqBuf = append(reqBuf, buf[:n]...)
if strings.Contains(string(reqBuf), "\r\n\r\n") {
break
}
}
if got := string(reqBuf); !strings.Contains(got, "GET /ports HTTP/1.1\r\n") {
serverDone <- fmt.Errorf("unexpected ports payload %q", got)
return
}
body := fmt.Sprintf(`{"listeners":[{"proto":"tcp","bind_address":"0.0.0.0","port":%d,"pid":44,"process":"python3","command":"python3 -m http.server %d"},{"proto":"tcp","bind_address":"0.0.0.0","port":%d,"pid":77,"process":"caddy","command":"caddy run"},{"proto":"udp","bind_address":"0.0.0.0","port":53,"pid":1,"process":"dnsd","command":"dnsd --foreground"}]}`, webAddr.Port, webAddr.Port, tlsAddr.Port)
resp := fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body)
_, err = conn.Write([]byte(resp))
serverDone <- err
}()
vm := testVM("ports", "image-ports", "127.0.0.1")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.APISockPath = apiSock
vm.Runtime.VSockPath = vsockSock
vm.Runtime.VSockCID = 10043
upsertDaemonVM(t, ctx, db, vm)
runner := &scriptedRunner{
t: t,
steps: []runnerStep{
sudoStep("", nil, "chmod", "600", vsockSock),
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), vsockSock),
},
}
d := &Daemon{store: db, runner: runner}
wireServices(d)
d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: fake.Process.Pid})
result, err := d.stats.PortsVM(ctx, vm.Name)
if err != nil {
t.Fatalf("PortsVM: %v", err)
}
if result.Name != vm.Name || result.DNSName != vm.Runtime.DNSName {
t.Fatalf("result = %+v, want name/dns", result)
}
if len(result.Ports) != 3 {
t.Fatalf("ports = %+v, want 3 entries", result.Ports)
}
wantHTTP := fmt.Sprintf("http://ports.vm:%d/", webAddr.Port)
wantHTTPS := fmt.Sprintf("https://ports.vm:%d/", tlsAddr.Port)
var httpPort, httpsPort, udpPort api.VMPort
for _, port := range result.Ports {
switch port.Port {
case webAddr.Port:
httpPort = port
case tlsAddr.Port:
httpsPort = port
case 53:
udpPort = port
}
}
if udpPort.Endpoint != "ports.vm:53" {
t.Fatalf("udp port = %+v, want endpoint only", udpPort)
}
if httpPort.Proto != "http" || httpPort.Endpoint != wantHTTP {
t.Fatalf("http port = %+v, want http endpoint %q", httpPort, wantHTTP)
}
if httpsPort.Proto != "https" || httpsPort.Endpoint != wantHTTPS {
t.Fatalf("https port = %+v, want https endpoint %q", httpsPort, wantHTTPS)
}
runner.assertExhausted()
if err := <-serverDone; err != nil {
t.Fatalf("server: %v", err)
}
}
func TestPortsVMReturnsErrorForStoppedVM(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
vm := testVM("stopped-ports", "image-stopped", "172.16.0.50")
upsertDaemonVM(t, ctx, db, vm)
d := &Daemon{store: db}
wireServices(d)
_, err := d.stats.PortsVM(ctx, vm.Name)
if err == nil || !strings.Contains(err.Error(), "is not running") {
t.Fatalf("PortsVM error = %v, want not running", err)
}
}
func TestBuildVMPortsDeduplicatesSameRenderedEndpoint(t *testing.T) {
t.Parallel()
vm := testVM("dedupe-ports", "image-ports", "")
vm.Runtime.DNSName = "dedupe-ports.vm"
ports := buildVMPorts(vm, []vsockagent.PortListener{
{
Proto: "tcp",
BindAddress: "0.0.0.0",
Port: 8080,
PID: 44,
Process: "docker-proxy",
Command: "/usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080",
},
{
Proto: "tcp",
BindAddress: "::",
Port: 8080,
PID: 45,
Process: "docker-proxy",
Command: "/usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 8080",
},
{
Proto: "udp",
BindAddress: "0.0.0.0",
Port: 8080,
PID: 46,
Process: "dnsd",
Command: "dnsd --foreground",
},
})
if len(ports) != 2 {
t.Fatalf("ports = %+v, want tcp+udp entries after dedupe", ports)
}
if ports[0].Proto != "tcp" || ports[0].Endpoint != "dedupe-ports.vm:8080" {
t.Fatalf("first port = %+v, want deduped tcp endpoint", ports[0])
}
if ports[1].Proto != "udp" || ports[1].Endpoint != "dedupe-ports.vm:8080" {
t.Fatalf("second port = %+v, want distinct udp endpoint", ports[1])
}
}
func TestSetVMDiskResizeFailsPreflightWhenToolsMissing(t *testing.T) {
ctx := context.Background()
db := openDaemonStore(t)
workDisk := filepath.Join(t.TempDir(), "root.ext4")
if err := os.WriteFile(workDisk, []byte("disk"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
vm := testVM("resize", "image-resize", "172.16.0.11")
vm.Runtime.WorkDiskPath = workDisk
vm.Spec.WorkDiskSizeBytes = 8 * 1024 * 1024 * 1024
upsertDaemonVM(t, ctx, db, vm)
t.Setenv("PATH", t.TempDir())
d := &Daemon{store: db}
wireServices(d)
_, err := d.vm.SetVM(ctx, api.VMSetParams{IDOrName: vm.ID, WorkDiskSize: "16G"})
if err == nil || !strings.Contains(err.Error(), "work disk resize preflight failed") {
t.Fatalf("SetVM() error = %v, want preflight failure", err)
}
}
func TestEnsureGitIdentityOnWorkDiskCopiesHostGlobalIdentity(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
testSetGitConfig(t, "user.name", "Banger Host")
testSetGitConfig(t, "user.email", "host@example.com")
workDiskDir := t.TempDir()
d := &Daemon{runner: &filesystemRunner{t: t}}
wireServices(d)
vm := testVM("git-identity", "image-git-identity", "172.16.0.67")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ws.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
}
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
if got := testGitConfigValue(t, guestConfigPath, "user.name"); got != "Banger Host" {
t.Fatalf("guest user.name = %q, want Banger Host", got)
}
if got := testGitConfigValue(t, guestConfigPath, "user.email"); got != "host@example.com" {
t.Fatalf("guest user.email = %q, want host@example.com", got)
}
}
func TestEnsureGitIdentityOnWorkDiskPreservesExistingGuestConfig(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
testSetGitConfig(t, "user.name", "Fresh Name")
testSetGitConfig(t, "user.email", "fresh@example.com")
workDiskDir := t.TempDir()
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
if err := os.WriteFile(guestConfigPath, []byte("[safe]\n\tdirectory = /root/repo\n[user]\n\tname = stale\n"), 0o644); err != nil {
t.Fatalf("WriteFile(guest .gitconfig): %v", err)
}
d := &Daemon{runner: &filesystemRunner{t: t}}
wireServices(d)
vm := testVM("git-identity-preserve", "image-git-identity", "172.16.0.68")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ws.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
}
if got := testGitConfigValue(t, guestConfigPath, "user.name"); got != "Fresh Name" {
t.Fatalf("guest user.name = %q, want Fresh Name", got)
}
if got := testGitConfigValue(t, guestConfigPath, "user.email"); got != "fresh@example.com" {
t.Fatalf("guest user.email = %q, want fresh@example.com", got)
}
if got := testGitConfigValue(t, guestConfigPath, "safe.directory"); got != "/root/repo" {
t.Fatalf("guest safe.directory = %q, want /root/repo", got)
}
}
func TestEnsureGitIdentityOnWorkDiskWarnsAndSkipsWhenHostIdentityIncomplete(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
testSetGitConfig(t, "user.name", "Only Name")
workDiskDir := t.TempDir()
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
original := []byte("[user]\n\temail = keep@example.com\n")
if err := os.WriteFile(guestConfigPath, original, 0o644); err != nil {
t.Fatalf("WriteFile(guest .gitconfig): %v", err)
}
var buf bytes.Buffer
logger, _, err := newDaemonLogger(&buf, "info")
if err != nil {
t.Fatalf("newDaemonLogger: %v", err)
}
d := &Daemon{
runner: &filesystemRunner{t: t},
logger: logger,
}
wireServices(d)
vm := testVM("git-identity-missing", "image-git-identity", "172.16.0.69")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ws.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
}
got, err := os.ReadFile(guestConfigPath)
if err != nil {
t.Fatalf("ReadFile(guest .gitconfig): %v", err)
}
if string(got) != string(original) {
t.Fatalf("guest .gitconfig = %q, want preserved %q", string(got), string(original))
}
entries := parseLogEntries(t, buf.Bytes())
if !hasLogEntry(entries, map[string]string{
"msg": "guest git identity sync skipped",
"vm_name": vm.Name,
"source": hostGlobalGitIdentitySource,
"error": "host git user.email is empty",
}) {
t.Fatalf("expected warn log, got %v", entries)
}
}
func TestRunFileSyncNoOpWhenConfigEmpty(t *testing.T) {
d := &Daemon{runner: &filesystemRunner{t: t}}
wireServices(d)
vm := testVM("no-sync", "image", "172.16.0.70")
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
}
func TestRunFileSyncCopiesFile(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
srcPath := filepath.Join(homeDir, ".secrets", "token")
if err := os.MkdirAll(filepath.Dir(srcPath), 0o755); err != nil {
t.Fatal(err)
}
srcData := []byte(`{"token":"abc"}`)
if err := os.WriteFile(srcPath, srcData, 0o600); err != nil {
t.Fatal(err)
}
workDisk := t.TempDir()
d := &Daemon{
runner: &filesystemRunner{t: t},
config: model.DaemonConfig{
FileSync: []model.FileSyncEntry{
{Host: "~/.secrets/token", Guest: "~/.secrets/token"},
},
},
}
wireServices(d)
vm := testVM("sync-file", "image", "172.16.0.71")
vm.Runtime.WorkDiskPath = workDisk
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
dst := filepath.Join(workDisk, ".secrets", "token")
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != string(srcData) {
t.Fatalf("dst = %q, want %q", got, srcData)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("mode = %v, want 0600", info.Mode().Perm())
}
}
func TestRunFileSyncRespectsCustomMode(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
srcPath := filepath.Join(homeDir, "script")
if err := os.WriteFile(srcPath, []byte("#!/bin/sh\nexit 0\n"), 0o600); err != nil {
t.Fatal(err)
}
workDisk := t.TempDir()
d := &Daemon{
runner: &filesystemRunner{t: t},
config: model.DaemonConfig{
FileSync: []model.FileSyncEntry{
{Host: "~/script", Guest: "~/bin/my-script", Mode: "0755"},
},
},
}
wireServices(d)
vm := testVM("sync-mode", "image", "172.16.0.72")
vm.Runtime.WorkDiskPath = workDisk
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
info, err := os.Stat(filepath.Join(workDisk, "bin", "my-script"))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o755 {
t.Fatalf("mode = %v, want 0755", info.Mode().Perm())
}
}
func TestRunFileSyncSkipsMissingHostPath(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
var buf bytes.Buffer
logger, _, err := newDaemonLogger(&buf, "info")
if err != nil {
t.Fatal(err)
}
workDisk := t.TempDir()
d := &Daemon{
runner: &filesystemRunner{t: t},
logger: logger,
config: model.DaemonConfig{
FileSync: []model.FileSyncEntry{
{Host: "~/does-not-exist", Guest: "~/wherever"},
},
},
}
wireServices(d)
vm := testVM("sync-missing", "image", "172.16.0.73")
vm.Runtime.WorkDiskPath = workDisk
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
entries := parseLogEntries(t, buf.Bytes())
if !hasLogEntry(entries, map[string]string{
"msg": "file_sync skipped",
"vm_name": vm.Name,
"host_path": filepath.Join(homeDir, "does-not-exist"),
}) {
t.Fatalf("expected skipped log, got %v", entries)
}
}
func TestRunFileSyncOverwritesExistingGuestFile(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
srcPath := filepath.Join(homeDir, "token")
if err := os.WriteFile(srcPath, []byte("fresh"), 0o600); err != nil {
t.Fatal(err)
}
workDisk := t.TempDir()
// Work disk is mounted at /root in the guest, so the guest path
// "/root/token" maps to workDisk/token here.
existing := filepath.Join(workDisk, "token")
if err := os.WriteFile(existing, []byte("stale"), 0o600); err != nil {
t.Fatal(err)
}
d := &Daemon{
runner: &filesystemRunner{t: t},
config: model.DaemonConfig{
FileSync: []model.FileSyncEntry{
{Host: "~/token", Guest: "/root/token"},
},
},
}
wireServices(d)
vm := testVM("sync-overwrite", "image", "172.16.0.74")
vm.Runtime.WorkDiskPath = workDisk
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
got, err := os.ReadFile(existing)
if err != nil {
t.Fatal(err)
}
if string(got) != "fresh" {
t.Fatalf("guest file = %q, want fresh", got)
}
}
func TestRunFileSyncCopiesDirectoryRecursively(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
srcDir := filepath.Join(homeDir, ".aws")
if err := os.MkdirAll(srcDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(srcDir, "credentials"), []byte("access"), 0o600); err != nil {
t.Fatal(err)
}
sub := filepath.Join(srcDir, "sso", "cache")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "token.json"), []byte("sso-token"), 0o600); err != nil {
t.Fatal(err)
}
workDisk := t.TempDir()
d := &Daemon{
runner: &filesystemRunner{t: t},
config: model.DaemonConfig{
FileSync: []model.FileSyncEntry{
{Host: "~/.aws", Guest: "~/.aws"},
},
},
}
wireServices(d)
vm := testVM("sync-dir", "image", "172.16.0.75")
vm.Runtime.WorkDiskPath = workDisk
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
creds, err := os.ReadFile(filepath.Join(workDisk, ".aws", "credentials"))
if err != nil {
t.Fatal(err)
}
if string(creds) != "access" {
t.Fatalf("credentials = %q, want access", creds)
}
ssoToken, err := os.ReadFile(filepath.Join(workDisk, ".aws", "sso", "cache", "token.json"))
if err != nil {
t.Fatal(err)
}
if string(ssoToken) != "sso-token" {
t.Fatalf("sso token = %q, want sso-token", ssoToken)
}
}
func TestRunFileSyncAllowsTopLevelSymlinkWithinHome(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
targetDir := filepath.Join(homeDir, ".config", "gh")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatal(err)
}
targetPath := filepath.Join(targetDir, "hosts.yml")
if err := os.WriteFile(targetPath, []byte("github.com"), 0o600); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(homeDir, "gh-hosts.yml")
if err := os.Symlink(targetPath, linkPath); err != nil {
t.Skipf("symlink unsupported on this filesystem: %v", err)
}
workDisk := t.TempDir()
d := &Daemon{
runner: &filesystemRunner{t: t},
config: model.DaemonConfig{
HostHomeDir: homeDir,
FileSync: []model.FileSyncEntry{
{Host: "~/gh-hosts.yml", Guest: "~/.config/gh/hosts.yml"},
},
},
}
wireServices(d)
vm := testVM("sync-top-level-symlink-ok", "image", "172.16.0.77")
vm.Runtime.WorkDiskPath = workDisk
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
got, err := os.ReadFile(filepath.Join(workDisk, ".config", "gh", "hosts.yml"))
if err != nil {
t.Fatal(err)
}
if string(got) != "github.com" {
t.Fatalf("guest file = %q, want github.com", got)
}
}
func TestRunFileSyncRejectsTopLevelSymlinkOutsideHome(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
outsideDir := t.TempDir()
targetPath := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(targetPath, []byte("must-stay-outside"), 0o600); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(homeDir, "secret-link")
if err := os.Symlink(targetPath, linkPath); err != nil {
t.Skipf("symlink unsupported on this filesystem: %v", err)
}
workDisk := t.TempDir()
d := &Daemon{
runner: &filesystemRunner{t: t},
config: model.DaemonConfig{
HostHomeDir: homeDir,
FileSync: []model.FileSyncEntry{
{Host: "~/secret-link", Guest: "~/secret.txt"},
},
},
}
wireServices(d)
vm := testVM("sync-top-level-symlink-reject", "image", "172.16.0.78")
vm.Runtime.WorkDiskPath = workDisk
err := d.ws.runFileSync(context.Background(), &vm)
if err == nil || !strings.Contains(err.Error(), "owner home") {
t.Fatalf("runFileSync error = %v, want owner-home rejection", err)
}
if _, statErr := os.Stat(filepath.Join(workDisk, "secret.txt")); !os.IsNotExist(statErr) {
t.Fatalf("guest file exists after rejected sync (stat err = %v)", statErr)
}
}
// TestRunFileSyncSkipsNestedSymlinks pins the anti-sprawl contract:
// a symlink INSIDE a synced directory is not followed, even if the
// target holds real files. Without this, a user syncing ~/.aws with
// a ~/.aws/session -> ~/other-creds symlink would copy the unrelated
// creds into the guest. Top-level entries are resolved separately:
// they may still follow, but only when the real target stays under
// the configured owner home.
func TestRunFileSyncSkipsNestedSymlinks(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
// Target the user DID NOT name — lives outside the synced tree.
outsideDir := filepath.Join(homeDir, "other-creds")
if err := os.MkdirAll(outsideDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outsideDir, "leaked.txt"), []byte("must-not-escape"), 0o600); err != nil {
t.Fatal(err)
}
// The synced directory.
srcDir := filepath.Join(homeDir, ".aws")
if err := os.MkdirAll(srcDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(srcDir, "credentials"), []byte("access"), 0o600); err != nil {
t.Fatal(err)
}
// File symlink inside .aws pointing OUT of the tree.
if err := os.Symlink(filepath.Join(outsideDir, "leaked.txt"), filepath.Join(srcDir, "session")); err != nil {
t.Skipf("symlink unsupported on this filesystem: %v", err)
}
// Directory symlink inside .aws pointing OUT of the tree — must
// not be recursed into.
if err := os.Symlink(outsideDir, filepath.Join(srcDir, "linked-dir")); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
logger, _, err := newDaemonLogger(&buf, "info")
if err != nil {
t.Fatal(err)
}
workDisk := t.TempDir()
d := &Daemon{
runner: &filesystemRunner{t: t},
logger: logger,
config: model.DaemonConfig{
FileSync: []model.FileSyncEntry{
{Host: "~/.aws", Guest: "~/.aws"},
},
},
}
wireServices(d)
vm := testVM("sync-symlink", "image", "172.16.0.76")
vm.Runtime.WorkDiskPath = workDisk
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
// The real file inside the tree must copy.
creds, err := os.ReadFile(filepath.Join(workDisk, ".aws", "credentials"))
if err != nil {
t.Fatalf("credentials not copied: %v", err)
}
if string(creds) != "access" {
t.Fatalf("credentials = %q, want access", creds)
}
// Neither the file symlink nor anything reached through the
// directory symlink should have been materialised in the guest
// path.
for _, shouldNotExist := range []string{
filepath.Join(workDisk, ".aws", "session"),
filepath.Join(workDisk, ".aws", "linked-dir"),
filepath.Join(workDisk, ".aws", "linked-dir", "leaked.txt"),
} {
if _, err := os.Stat(shouldNotExist); !os.IsNotExist(err) {
t.Fatalf("symlinked path %s was materialised in guest tree (stat err = %v); secret leakage path open", shouldNotExist, err)
}
}
// Each skipped symlink must be warned.
entries := parseLogEntries(t, buf.Bytes())
for _, want := range []string{
filepath.Join(srcDir, "session"),
filepath.Join(srcDir, "linked-dir"),
} {
if !hasLogEntry(entries, map[string]string{
"msg": "file_sync skipped symlink (would escape the requested tree)",
"vm_name": vm.Name,
"host_path": want,
}) {
t.Fatalf("expected warn log for skipped symlink %s; got %v", want, entries)
}
}
}
func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) {
d := &Daemon{}
wireServices(d)
if _, err := d.vm.CreateVM(context.Background(), api.VMCreateParams{VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
t.Fatalf("CreateVM(vcpu=0) error = %v", err)
}
if _, err := d.vm.CreateVM(context.Background(), api.VMCreateParams{MemoryMiB: ptr(-1)}); err == nil || !strings.Contains(err.Error(), "memory must be a positive integer") {
t.Fatalf("CreateVM(memory=-1) error = %v", err)
}
}
func TestBeginVMCreateCompletesAndReturnsStatus(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
image := testImage("default")
image.ID = "default-image-id"
image.Name = "default"
if err := db.UpsertImage(ctx, image); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
d := &Daemon{
store: db,
layout: paths.Layout{
VMsDir: t.TempDir(),
},
config: model.DaemonConfig{
DefaultImageName: image.Name,
BridgeIP: model.DefaultBridgeIP,
},
}
wireServices(d)
op, err := d.vm.BeginVMCreate(ctx, api.VMCreateParams{Name: "queued", NoStart: true})
if err != nil {
t.Fatalf("BeginVMCreate: %v", err)
}
if op.ID == "" {
t.Fatal("operation id should be populated")
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
status, err := d.vm.VMCreateStatus(ctx, op.ID)
if err != nil {
t.Fatalf("VMCreateStatus: %v", err)
}
if !status.Done {
time.Sleep(10 * time.Millisecond)
continue
}
if !status.Success {
t.Fatalf("status = %+v, want success", status)
}
if status.VM == nil || status.VM.Name != "queued" {
t.Fatalf("status VM = %+v, want queued vm", status.VM)
}
if status.VM.State != model.VMStateStopped {
t.Fatalf("status VM state = %s, want stopped", status.VM.State)
}
return
}
t.Fatal("vm create operation did not finish before timeout")
}
func TestCreateVMUsesDefaultsWhenCPUAndMemoryOmitted(t *testing.T) {
ctx := context.Background()
db := openDaemonStore(t)
image := testImage("default")
if err := db.UpsertImage(ctx, image); err != nil {
t.Fatalf("UpsertImage: %v", err)
}
d := &Daemon{
store: db,
layout: paths.Layout{
VMsDir: t.TempDir(),
},
config: model.DaemonConfig{
DefaultImageName: image.Name,
BridgeIP: model.DefaultBridgeIP,
},
}
wireServices(d)
vm, err := d.vm.CreateVM(ctx, api.VMCreateParams{Name: "defaults", ImageName: image.Name, NoStart: true})
if err != nil {
t.Fatalf("CreateVM: %v", err)
}
if vm.Spec.VCPUCount != model.DefaultVCPUCount {
t.Fatalf("VCPUCount = %d, want %d", vm.Spec.VCPUCount, model.DefaultVCPUCount)
}
if vm.Spec.MemoryMiB != model.DefaultMemoryMiB {
t.Fatalf("MemoryMiB = %d, want %d", vm.Spec.MemoryMiB, model.DefaultMemoryMiB)
}
}
func TestSetVMRejectsNonPositiveCPUAndMemory(t *testing.T) {
ctx := context.Background()
db := openDaemonStore(t)
vm := testVM("validate", "image-validate", "172.16.0.13")
upsertDaemonVM(t, ctx, db, vm)
d := &Daemon{store: db}
wireServices(d)
if _, err := d.vm.SetVM(ctx, api.VMSetParams{IDOrName: vm.ID, VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
t.Fatalf("SetVM(vcpu=0) error = %v", err)
}
if _, err := d.vm.SetVM(ctx, api.VMSetParams{IDOrName: vm.ID, MemoryMiB: ptr(0)}); err == nil || !strings.Contains(err.Error(), "memory must be a positive integer") {
t.Fatalf("SetVM(memory=0) error = %v", err)
}
}
func TestCollectStatsIgnoresMalformedMetricsFile(t *testing.T) {
t.Parallel()
overlay := filepath.Join(t.TempDir(), "system.cow")
workDisk := filepath.Join(t.TempDir(), "root.ext4")
metrics := filepath.Join(t.TempDir(), "metrics.json")
for _, path := range []string{overlay, workDisk} {
if err := os.WriteFile(path, []byte("allocated"), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
if err := os.WriteFile(metrics, []byte("{not-json}\n"), 0o644); err != nil {
t.Fatalf("WriteFile(metrics): %v", err)
}
d := &Daemon{}
wireServices(d)
stats, err := d.stats.collectStats(context.Background(), model.VMRecord{
Runtime: model.VMRuntime{
SystemOverlay: overlay,
WorkDiskPath: workDisk,
MetricsPath: metrics,
},
})
if err != nil {
t.Fatalf("collectStats: %v", err)
}
if stats.MetricsRaw != nil {
t.Fatalf("MetricsRaw = %v, want nil for malformed metrics", stats.MetricsRaw)
}
if stats.SystemOverlayBytes == 0 || stats.WorkDiskBytes == 0 {
t.Fatalf("allocated bytes not captured: %+v", stats)
}
}
func TestValidateStartPrereqsReportsNATUplinkFailure(t *testing.T) {
ctx := context.Background()
binDir := t.TempDir()
for _, name := range []string{
"sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep", "ps",
"chown", "chmod", "kill", "e2cp", "e2rm", "debugfs", "mkfs.ext4", "mount",
"umount", "cp", "iptables", "sysctl",
} {
writeFakeExecutable(t, filepath.Join(binDir, name))
}
t.Setenv("PATH", binDir)
firecrackerBin := filepath.Join(t.TempDir(), "firecracker")
rootfsPath := filepath.Join(t.TempDir(), "rootfs.ext4")
kernelPath := filepath.Join(t.TempDir(), "vmlinux")
for _, path := range []string{firecrackerBin, rootfsPath, kernelPath} {
writeFakeExecutable(t, path)
}
runner := &scriptedRunner{
t: t,
steps: []runnerStep{
{call: runnerCall{name: "ip", args: []string{"route", "show", "default"}}, out: []byte("10.0.0.0/24 dev br-fc\n")},
},
}
d := &Daemon{
runner: runner,
config: model.DaemonConfig{
FirecrackerBin: firecrackerBin,
},
}
wireServices(d)
vm := testVM("nat", "image-nat", "172.16.0.12")
vm.Spec.NATEnabled = true
vm.Runtime.WorkDiskPath = filepath.Join(t.TempDir(), "missing-root.ext4")
image := testImage("image-nat")
image.RootfsPath = rootfsPath
image.KernelPath = kernelPath
err := d.vm.validateStartPrereqs(ctx, vm, image)
if err == nil || !strings.Contains(err.Error(), "uplink interface for NAT") {
t.Fatalf("validateStartPrereqs() error = %v, want NAT uplink failure", err)
}
runner.assertExhausted()
}
func TestCleanupRuntimeRediscoversLiveFirecrackerPID(t *testing.T) {
apiSock := filepath.Join(t.TempDir(), "fc.sock")
fake := startFakeFirecrackerProcess(t, apiSock)
t.Cleanup(func() {
if fake.ProcessState == nil || !fake.ProcessState.Exited() {
_ = fake.Process.Kill()
_ = fake.Wait()
}
})
runner := &processKillingRunner{
scriptedRunner: &scriptedRunner{
t: t,
steps: []runnerStep{
{call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, out: []byte(strconv.Itoa(fake.Process.Pid) + "\n")},
sudoStep("", nil, "kill", "-KILL", strconv.Itoa(fake.Process.Pid)),
},
},
proc: fake,
}
d := &Daemon{runner: runner}
wireServices(d)
vm := testVM("cleanup", "image-cleanup", "172.16.0.22")
vm.Runtime.APISockPath = apiSock
// Seed a stale PID so cleanupRuntime's findFirecrackerPID pgrep
// fallback wins — it rediscovers fake.Process.Pid from apiSock.
d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: fake.Process.Pid + 999})
if err := d.vm.cleanupRuntime(context.Background(), vm, true); err != nil {
t.Fatalf("cleanupRuntime returned error: %v", err)
}
runner.assertExhausted()
if systemProcessRunning(fake.Process.Pid, apiSock) {
t.Fatalf("fake firecracker pid %d still looks running", fake.Process.Pid)
}
}
func TestDeleteStoppedNATVMDoesNotFailWithoutTapDevice(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openDaemonStore(t)
vmDir := filepath.Join(t.TempDir(), "stopped-nat-vm")
if err := os.MkdirAll(vmDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
vm := testVM("stopped-nat", "image-stopped-nat", "172.16.0.24")
vm.Spec.NATEnabled = true
vm.Runtime.VMDir = vmDir
vm.State = model.VMStateStopped
vm.Runtime.State = model.VMStateStopped
upsertDaemonVM(t, ctx, db, vm)
d := &Daemon{store: db}
wireServices(d)
deleted, err := d.vm.DeleteVM(ctx, vm.Name)
if err != nil {
t.Fatalf("DeleteVM: %v", err)
}
if deleted.ID != vm.ID {
t.Fatalf("deleted VM = %+v, want %s", deleted, vm.ID)
}
if _, err := db.GetVMByID(ctx, vm.ID); err == nil {
t.Fatal("expected VM record to be deleted")
}
if _, err := os.Stat(vmDir); !os.IsNotExist(err) {
t.Fatalf("vm dir still exists or stat failed: %v", err)
}
}
func TestStopVMFallsBackToForcedCleanupAfterGracefulTimeout(t *testing.T) {
ctx := context.Background()
db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "fc.sock")
startFakeFirecrackerAPI(t, apiSock)
fake := startFakeFirecrackerProcess(t, apiSock)
t.Cleanup(func() {
if fake.ProcessState == nil || !fake.ProcessState.Exited() {
_ = fake.Process.Kill()
_ = fake.Wait()
}
})
oldGracefulWait := gracefulShutdownWait
gracefulShutdownWait = 50 * time.Millisecond
t.Cleanup(func() {
gracefulShutdownWait = oldGracefulWait
})
vm := testVM("stubborn", "image-stubborn", "172.16.0.23")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.APISockPath = apiSock
upsertDaemonVM(t, ctx, db, vm)
runner := &processKillingRunner{
scriptedRunner: &scriptedRunner{
t: t,
steps: []runnerStep{
sudoStep("", nil, "chmod", "600", apiSock),
sudoStep("", nil, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), apiSock),
{call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, out: []byte(strconv.Itoa(fake.Process.Pid) + "\n")},
sudoStep("", nil, "kill", "-KILL", strconv.Itoa(fake.Process.Pid)),
},
},
proc: fake,
}
d := &Daemon{store: db, runner: runner}
wireServices(d)
d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: fake.Process.Pid})
got, err := d.vm.StopVM(ctx, vm.ID)
if err != nil {
t.Fatalf("StopVM returned error: %v", err)
}
runner.assertExhausted()
if got.State != model.VMStateStopped || got.Runtime.State != model.VMStateStopped {
t.Fatalf("StopVM state = %s/%s, want stopped", got.State, got.Runtime.State)
}
// APISockPath + VSock paths are deterministic — they stay on the
// record for debugging and next-start reuse even after stop. The
// post-stop invariant is that the in-memory cache is empty.
if h, ok := d.vm.handles.get(vm.ID); ok && !h.IsZero() {
t.Fatalf("handle cache not cleared: %+v", h)
}
}
func TestWithVMLockByIDSerializesSameVM(t *testing.T) {
ctx := context.Background()
db := openDaemonStore(t)
vm := testVM("serial", "image-serial", "172.16.0.30")
upsertDaemonVM(t, ctx, db, vm)
d := &Daemon{store: db}
wireServices(d)
firstEntered := make(chan struct{})
releaseFirst := make(chan struct{})
secondEntered := make(chan struct{})
errCh := make(chan error, 2)
go func() {
_, err := d.vm.withVMLockByID(ctx, vm.ID, func(vm model.VMRecord) (model.VMRecord, error) {
close(firstEntered)
<-releaseFirst
return vm, nil
})
errCh <- err
}()
select {
case <-firstEntered:
case <-time.After(500 * time.Millisecond):
t.Fatal("first lock holder did not enter")
}
go func() {
_, err := d.vm.withVMLockByID(ctx, vm.ID, func(vm model.VMRecord) (model.VMRecord, error) {
close(secondEntered)
return vm, nil
})
errCh <- err
}()
select {
case <-secondEntered:
t.Fatal("second same-vm lock holder entered before release")
case <-time.After(150 * time.Millisecond):
}
close(releaseFirst)
select {
case <-secondEntered:
case <-time.After(500 * time.Millisecond):
t.Fatal("second same-vm lock holder never entered")
}
for i := 0; i < 2; i++ {
if err := <-errCh; err != nil {
t.Fatalf("withVMLockByID returned error: %v", err)
}
}
}
func TestWithVMLockByIDAllowsDifferentVMsConcurrently(t *testing.T) {
ctx := context.Background()
db := openDaemonStore(t)
vmA := testVM("alpha-lock", "image-alpha", "172.16.0.31")
vmB := testVM("bravo-lock", "image-bravo", "172.16.0.32")
for _, vm := range []model.VMRecord{vmA, vmB} {
upsertDaemonVM(t, ctx, db, vm)
}
d := &Daemon{store: db}
wireServices(d)
started := make(chan string, 2)
release := make(chan struct{})
errCh := make(chan error, 2)
run := func(id string) {
_, err := d.vm.withVMLockByID(ctx, id, func(vm model.VMRecord) (model.VMRecord, error) {
started <- vm.ID
<-release
return vm, nil
})
errCh <- err
}
go run(vmA.ID)
go run(vmB.ID)
for i := 0; i < 2; i++ {
select {
case <-started:
case <-time.After(500 * time.Millisecond):
t.Fatal("different VM locks did not overlap")
}
}
close(release)
for i := 0; i < 2; i++ {
if err := <-errCh; err != nil {
t.Fatalf("withVMLockByID returned error: %v", err)
}
}
}
func openDaemonStore(t *testing.T) *store.Store {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() {
_ = db.Close()
})
return db
}
func upsertDaemonVM(t *testing.T, ctx context.Context, db *store.Store, vm model.VMRecord) {
t.Helper()
image := testImage(vm.ImageID)
image.ID = vm.ImageID
image.Name = vm.ImageID
if err := db.UpsertImage(ctx, image); err != nil {
t.Fatalf("UpsertImage(%s): %v", image.Name, err)
}
if err := db.UpsertVM(ctx, vm); err != nil {
t.Fatalf("UpsertVM(%s): %v", vm.Name, err)
}
}
func testVM(name, imageID, guestIP string) model.VMRecord {
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
return model.VMRecord{
ID: name + "-id",
Name: name,
ImageID: imageID,
State: model.VMStateStopped,
CreatedAt: now,
UpdatedAt: now,
LastTouchedAt: now,
Spec: model.VMSpec{
VCPUCount: 2,
MemoryMiB: 1024,
SystemOverlaySizeByte: model.DefaultSystemOverlaySize,
WorkDiskSizeBytes: model.DefaultWorkDiskSize,
},
Runtime: model.VMRuntime{
State: model.VMStateStopped,
GuestIP: guestIP,
DNSName: name + ".vm",
VMDir: filepath.Join("/state", name),
SystemOverlay: filepath.Join("/state", name, "system.cow"),
WorkDiskPath: filepath.Join("/state", name, "root.ext4"),
},
}
}
func testImage(name string) model.Image {
now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC)
return model.Image{
ID: name + "-id",
Name: name,
RootfsPath: filepath.Join("/images", name+".ext4"),
KernelPath: filepath.Join("/kernels", name),
CreatedAt: now,
UpdatedAt: now,
}
}
func TestMergeAuthorizedKey(t *testing.T) {
t.Parallel()
managed := []byte("ssh-ed25519 AAAATESTKEY banger\n")
existing := []byte("ssh-ed25519 AAAAOTHER other\n")
merged := mergeAuthorizedKey(existing, managed)
got := string(merged)
if !strings.Contains(got, "ssh-ed25519 AAAAOTHER other") {
t.Fatalf("merged keys dropped existing entry: %q", got)
}
if !strings.Contains(got, "ssh-ed25519 AAAATESTKEY banger") {
t.Fatalf("merged keys missing managed entry: %q", got)
}
if strings.Count(got, "ssh-ed25519 AAAATESTKEY banger") != 1 {
t.Fatalf("managed key duplicated in %q", got)
}
merged = mergeAuthorizedKey(merged, managed)
if strings.Count(string(merged), "ssh-ed25519 AAAATESTKEY banger") != 1 {
t.Fatalf("managed key duplicated after second merge: %q", string(merged))
}
}
func startFakeFirecrackerProcess(t *testing.T, apiSock string) *exec.Cmd {
t.Helper()
cmd := exec.Command("bash", "-lc", fmt.Sprintf("exec -a %q sleep 30", "firecracker --api-sock "+apiSock))
if err := cmd.Start(); err != nil {
t.Fatalf("start fake firecracker: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cmd.Process != nil && cmd.Process.Pid > 0 && systemProcessRunning(cmd.Process.Pid, apiSock) {
return cmd
}
time.Sleep(20 * time.Millisecond)
}
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("fake firecracker process never looked running for %s", apiSock)
return nil
}
func startFakeFirecrackerAPI(t *testing.T, apiSock string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(apiSock), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(apiSock), err)
}
listener, err := net.Listen("unix", apiSock)
if err != nil {
skipIfSocketRestricted(t, err)
t.Fatalf("listen unix %s: %v", apiSock, err)
}
mux := http.NewServeMux()
mux.HandleFunc("/actions", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusNoContent)
})
server := &http.Server{Handler: mux}
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
_ = server.Close()
_ = os.Remove(apiSock)
})
}
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)
}
func testSetGitConfig(t *testing.T, key, value string) {
t.Helper()
cmd := exec.Command("git", "config", "--global", key, value)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config --global %s: %v: %s", key, err, strings.TrimSpace(string(output)))
}
}
func testGitConfigValue(t *testing.T, configPath, key string) string {
t.Helper()
cmd := exec.Command("git", "config", "--file", configPath, "--get", key)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config --file %s --get %s: %v: %s", configPath, key, err, strings.TrimSpace(string(output)))
}
return strings.TrimSpace(string(output))
}
type processKillingRunner struct {
*scriptedRunner
proc *exec.Cmd
}
type filesystemRunner struct {
t *testing.T
}
func (r *filesystemRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
r.t.Helper()
if name == "git" {
cmd := exec.CommandContext(ctx, name, args...)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return stdout.Bytes(), fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return stdout.Bytes(), err
}
return stdout.Bytes(), nil
}
return nil, fmt.Errorf("unexpected Run call: %s %v", name, args)
}
func (r *filesystemRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
r.t.Helper()
if len(args) == 0 {
return nil, errors.New("missing sudo command")
}
switch args[0] {
case "mount":
if len(args) != 3 {
return nil, fmt.Errorf("unexpected mount args: %v", args)
}
source, mountDir := args[1], args[2]
if err := os.Remove(mountDir); err != nil {
return nil, err
}
if err := os.Symlink(source, mountDir); err != nil {
return nil, err
}
return nil, nil
case "umount":
return nil, nil
case "chmod":
if len(args) != 3 {
return nil, fmt.Errorf("unexpected chmod args: %v", args)
}
mode, err := strconv.ParseUint(args[1], 8, 32)
if err != nil {
return nil, err
}
return nil, os.Chmod(args[2], os.FileMode(mode))
case "cp":
if len(args) != 4 || args[1] != "-a" {
return nil, fmt.Errorf("unexpected cp args: %v", args)
}
return nil, copyIntoDir(args[2], args[3])
case "rm":
if len(args) != 3 || args[1] != "-rf" {
return nil, fmt.Errorf("unexpected rm args: %v", args)
}
return nil, os.RemoveAll(args[2])
case "mkdir":
if len(args) != 3 || args[1] != "-p" {
return nil, fmt.Errorf("unexpected mkdir args: %v", args)
}
return nil, os.MkdirAll(args[2], 0o755)
case "cat":
if len(args) != 2 {
return nil, fmt.Errorf("unexpected cat args: %v", args)
}
return os.ReadFile(args[1])
case "install":
// Minimal install(1): expected forms are
// install -m MODE SRC DST (5 args)
// install -o 0 -g 0 -m MODE SRC DST (9 args, ignored owners)
src, dst, mode, err := parseInstallArgs(args)
if err != nil {
return nil, err
}
data, err := os.ReadFile(src)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return nil, err
}
return nil, os.WriteFile(dst, data, os.FileMode(mode))
case "chown":
// Recognised forms, both no-op under test (we run as the test
// user and os.Chown would need CAP_CHOWN):
// chown OWNER TARGET
// chown -R OWNER TARGET
switch {
case len(args) == 3:
return nil, nil
case len(args) == 4 && args[1] == "-R":
return nil, nil
default:
return nil, fmt.Errorf("unexpected chown args: %v", args)
}
case "debugfs":
return runFakeDebugfs(args[1:])
case "e2cp":
// e2cp SRC IMAGE:/GUEST → plain file copy into IMAGE dir
if len(args) != 3 {
return nil, fmt.Errorf("unexpected e2cp args: %v", args)
}
image, guest, ok := splitImageColonPath(args[2])
if !ok {
return nil, fmt.Errorf("e2cp dst missing image:path separator: %v", args)
}
target := filepath.Join(image, guest)
data, err := os.ReadFile(args[1])
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil, err
}
return nil, os.WriteFile(target, data, 0o600)
case "e2rm":
// e2rm IMAGE:/GUEST → plain file delete; missing is not fatal
if len(args) != 2 {
return nil, fmt.Errorf("unexpected e2rm args: %v", args)
}
image, guest, ok := splitImageColonPath(args[1])
if !ok {
return nil, fmt.Errorf("e2rm missing image:path separator: %v", args)
}
target := filepath.Join(image, guest)
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
return nil, err
}
return nil, nil
default:
return nil, fmt.Errorf("unexpected sudo command: %v", args)
}
}
// runFakeDebugfs emulates the subset of debugfs commands the ext4
// toolkit drives in per-line mode (the stdin-batched path doesn't run
// under filesystemRunner because it doesn't implement StdinRunner).
// Supported: stat/cat, plus -w mkdir/set_inode_field. Inode 2 <2>
// set_inode_field is a no-op — tests don't care about root-inode mode
// beyond it not exploding.
func runFakeDebugfs(args []string) ([]byte, error) {
// Forms:
// debugfs -R "<cmd>" <image> (read-only)
// debugfs -w -R "<cmd>" <image> (single write)
if len(args) < 3 {
return nil, fmt.Errorf("unexpected debugfs args: %v", args)
}
write := false
rest := args
if rest[0] == "-w" {
write = true
rest = rest[1:]
}
if len(rest) != 3 || rest[0] != "-R" {
return nil, fmt.Errorf("unexpected debugfs args: %v", args)
}
cmdLine := strings.TrimSpace(rest[1])
image := rest[2]
fields := strings.Fields(cmdLine)
if len(fields) == 0 {
return nil, fmt.Errorf("empty debugfs command")
}
switch fields[0] {
case "stat":
if len(fields) != 2 {
return nil, fmt.Errorf("unexpected debugfs stat: %q", cmdLine)
}
target := filepath.Join(image, strings.Trim(fields[1], `"`))
if _, err := os.Stat(target); err != nil {
if os.IsNotExist(err) {
return []byte("stat: File not found by ext2_lookup while starting pathname"), nil
}
return nil, err
}
return []byte("Inode: 12 Type: directory"), nil
case "cat":
if len(fields) != 2 {
return nil, fmt.Errorf("unexpected debugfs cat: %q", cmdLine)
}
target := filepath.Join(image, strings.Trim(fields[1], `"`))
data, err := os.ReadFile(target)
if err != nil {
return nil, err
}
return data, nil
case "mkdir":
if !write {
return nil, fmt.Errorf("debugfs mkdir requires -w: %q", cmdLine)
}
if len(fields) != 2 {
return nil, fmt.Errorf("unexpected debugfs mkdir: %q", cmdLine)
}
target := filepath.Join(image, strings.Trim(fields[1], `"`))
return nil, os.MkdirAll(target, 0o755)
case "set_inode_field":
// set_inode_field <path-or-<2>> <field> <value>
// Mode changes on non-root targets: honour the perm bits so
// tests can assert file mode. Root inode <2>, uid, gid are
// no-ops — tests don't inspect them.
if !write {
return nil, fmt.Errorf("debugfs set_inode_field requires -w: %q", cmdLine)
}
if len(fields) != 4 {
return nil, fmt.Errorf("unexpected set_inode_field: %q", cmdLine)
}
target := strings.Trim(fields[1], `"`)
if target == "<2>" || fields[2] != "mode" {
return nil, nil
}
raw := strings.TrimPrefix(fields[3], "0")
v, err := strconv.ParseUint(raw, 8, 32)
if err != nil {
return nil, fmt.Errorf("parse set_inode_field mode %q: %w", fields[3], err)
}
return nil, os.Chmod(filepath.Join(image, target), os.FileMode(v)&os.ModePerm)
case "rdump":
// rdump <src> <dst>
return nil, fmt.Errorf("rdump not supported in filesystemRunner")
default:
return nil, fmt.Errorf("unsupported debugfs cmd: %q", cmdLine)
}
}
// splitImageColonPath splits an e2cp/e2rm "image:path" argument.
// Returns image, path, true on success. Only the LAST colon is split
// on since image paths on disk may contain one (rare) and guest paths
// always start with "/".
func splitImageColonPath(arg string) (string, string, bool) {
idx := strings.LastIndex(arg, ":/")
if idx < 0 {
return "", "", false
}
return arg[:idx], arg[idx+1:], true
}
// parseInstallArgs recognises the `install` invocations banger emits
// and returns (source, destination, parsed mode). Anything else is an
// error so the test stub stays a closed set.
func parseInstallArgs(args []string) (string, string, os.FileMode, error) {
switch len(args) {
case 5:
if args[1] != "-m" {
return "", "", 0, fmt.Errorf("unexpected install args: %v", args)
}
mode, err := strconv.ParseUint(args[2], 8, 32)
if err != nil {
return "", "", 0, err
}
return args[3], args[4], os.FileMode(mode), nil
case 9:
if args[1] != "-o" || args[3] != "-g" || args[5] != "-m" {
return "", "", 0, fmt.Errorf("unexpected install args: %v", args)
}
mode, err := strconv.ParseUint(args[6], 8, 32)
if err != nil {
return "", "", 0, err
}
return args[7], args[8], os.FileMode(mode), nil
}
return "", "", 0, fmt.Errorf("unexpected install args: %v", args)
}
func copyIntoDir(sourcePath, targetDir string) error {
targetDir = strings.TrimSuffix(targetDir, "/")
info, err := os.Stat(sourcePath)
if err != nil {
return err
}
destPath := filepath.Join(targetDir, filepath.Base(sourcePath))
if info.IsDir() {
if err := os.MkdirAll(destPath, info.Mode().Perm()); err != nil {
return err
}
entries, err := os.ReadDir(sourcePath)
if err != nil {
return err
}
for _, entry := range entries {
if err := copyIntoDir(filepath.Join(sourcePath, entry.Name()), destPath); err != nil {
return err
}
}
return os.Chmod(destPath, info.Mode().Perm())
}
data, err := os.ReadFile(sourcePath)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return err
}
return os.WriteFile(destPath, data, info.Mode().Perm())
}
func (r *processKillingRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
return r.scriptedRunner.Run(ctx, name, args...)
}
func (r *processKillingRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
out, err := r.scriptedRunner.RunSudo(ctx, args...)
if err != nil {
return out, err
}
if len(args) >= 3 && args[0] == "kill" && args[1] == "-KILL" && r.proc != nil && (r.proc.ProcessState == nil || !r.proc.ProcessState.Exited()) {
_ = r.proc.Process.Kill()
_ = r.proc.Wait()
}
return out, nil
}
func systemProcessRunning(pid int, apiSock string) bool {
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
if err != nil {
return false
}
cmdline := strings.ReplaceAll(string(data), "\x00", " ")
return strings.Contains(cmdline, "firecracker") && strings.Contains(cmdline, apiSock)
}
func writeFakeExecutable(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
func ptr[T any](value T) *T {
return &value
}