Add Go daemon-driven VM control plane
Replace the shell-only user workflow with `banger` and `bangerd`: Cobra commands, XDG/SQLite-backed state, managed VM and image lifecycle, and a Bubble Tea TUI for browsing and operating VMs.\n\nKeep Firecracker orchestration behind the daemon so VM specs become persistent objects, and add repo entrypoints for building, installing, and documenting the new flow while still delegating rootfs customization to the existing shell tooling.\n\nHarden the control plane around real usage by reclaiming Firecracker API sockets for the user, restarting stale daemons after rebuilds, and returning the correct `vm.create` payload so the CLI and TUI creation flow work reliably.\n\nValidation: `go test ./...`, `make build`, and a host-side smoke test with `./banger vm create --name codex-smoke`.
This commit is contained in:
parent
3cf33d1e0a
commit
ea72ea26fe
22 changed files with 5480 additions and 0 deletions
420
internal/daemon/daemon.go
Normal file
420
internal/daemon/daemon.go
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/config"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/rpc"
|
||||
"banger/internal/store"
|
||||
"banger/internal/system"
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
layout paths.Layout
|
||||
config model.DaemonConfig
|
||||
store *store.Store
|
||||
runner system.Runner
|
||||
mu sync.Mutex
|
||||
closing chan struct{}
|
||||
once sync.Once
|
||||
pid int
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
func Open(ctx context.Context) (*Daemon, error) {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := paths.Ensure(layout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := config.Load(layout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := store.Open(layout.DBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &Daemon{
|
||||
layout: layout,
|
||||
config: cfg,
|
||||
store: db,
|
||||
runner: system.NewRunner(),
|
||||
closing: make(chan struct{}),
|
||||
pid: os.Getpid(),
|
||||
}
|
||||
if err := d.ensureDefaultImage(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.reconcile(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) Close() error {
|
||||
var err error
|
||||
d.once.Do(func() {
|
||||
close(d.closing)
|
||||
if d.listener != nil {
|
||||
_ = d.listener.Close()
|
||||
}
|
||||
err = d.store.Close()
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) Serve(ctx context.Context) error {
|
||||
_ = os.Remove(d.layout.SocketPath)
|
||||
listener, err := net.Listen("unix", d.layout.SocketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.listener = listener
|
||||
defer listener.Close()
|
||||
defer os.Remove(d.layout.SocketPath)
|
||||
if err := os.Chmod(d.layout.SocketPath, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go d.backgroundLoop()
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-d.closing:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Temporary() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
go d.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
var req rpc.Request
|
||||
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&req); err != nil {
|
||||
_ = json.NewEncoder(conn).Encode(rpc.NewError("bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp := d.dispatch(req)
|
||||
_ = json.NewEncoder(conn).Encode(resp)
|
||||
}
|
||||
|
||||
func (d *Daemon) dispatch(req rpc.Request) rpc.Response {
|
||||
if req.Version != rpc.Version {
|
||||
return rpc.NewError("bad_version", fmt.Sprintf("unsupported version %d", req.Version))
|
||||
}
|
||||
ctx := context.Background()
|
||||
switch req.Method {
|
||||
case "ping":
|
||||
result, _ := rpc.NewResult(api.PingResult{Status: "ok", PID: d.pid})
|
||||
return result
|
||||
case "shutdown":
|
||||
go d.Close()
|
||||
result, _ := rpc.NewResult(api.ShutdownResult{Status: "stopping"})
|
||||
return result
|
||||
case "vm.create":
|
||||
params, err := rpc.DecodeParams[api.VMCreateParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.CreateVM(ctx, params)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.list":
|
||||
vms, err := d.store.ListVMs(ctx)
|
||||
return marshalResultOrError(api.VMListResult{VMs: vms}, err)
|
||||
case "vm.show":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.FindVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.start":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.StartVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.stop":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.StopVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.restart":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.RestartVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.delete":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.DeleteVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.set":
|
||||
params, err := rpc.DecodeParams[api.VMSetParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.SetVM(ctx, params)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.stats":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, stats, err := d.GetVMStats(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMStatsResult{VM: vm, Stats: stats}, err)
|
||||
case "vm.logs":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.FindVM(ctx, params.IDOrName)
|
||||
if err != nil {
|
||||
return rpc.NewError("not_found", err.Error())
|
||||
}
|
||||
return marshalResultOrError(api.VMLogsResult{LogPath: vm.Runtime.LogPath}, nil)
|
||||
case "vm.ssh":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.TouchVM(ctx, params.IDOrName)
|
||||
if err != nil {
|
||||
return rpc.NewError("not_found", err.Error())
|
||||
}
|
||||
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
return rpc.NewError("not_running", fmt.Sprintf("vm %s is not running", vm.Name))
|
||||
}
|
||||
return marshalResultOrError(api.VMSSHResult{Name: vm.Name, GuestIP: vm.Runtime.GuestIP}, nil)
|
||||
case "image.list":
|
||||
images, err := d.store.ListImages(ctx)
|
||||
return marshalResultOrError(api.ImageListResult{Images: images}, err)
|
||||
case "image.show":
|
||||
params, err := rpc.DecodeParams[api.ImageRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
image, err := d.FindImage(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
case "image.build":
|
||||
params, err := rpc.DecodeParams[api.ImageBuildParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
image, err := d.BuildImage(ctx, params)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
case "image.delete":
|
||||
params, err := rpc.DecodeParams[api.ImageRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
image, err := d.DeleteImage(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
default:
|
||||
return rpc.NewError("unknown_method", req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) backgroundLoop() {
|
||||
statsTicker := time.NewTicker(d.config.StatsPollInterval)
|
||||
staleTicker := time.NewTicker(model.DefaultStaleSweepInterval)
|
||||
defer statsTicker.Stop()
|
||||
defer staleTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-d.closing:
|
||||
return
|
||||
case <-statsTicker.C:
|
||||
_ = d.pollStats(context.Background())
|
||||
case <-staleTicker.C:
|
||||
_ = d.stopStaleVMs(context.Background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureDefaultImage(ctx context.Context) error {
|
||||
if d.config.DefaultImageName == "" || d.config.RepoRoot == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := d.store.GetImageByName(ctx, d.config.DefaultImageName); err == nil {
|
||||
return nil
|
||||
}
|
||||
rootfs := filepath.Join(d.config.RepoRoot, "rootfs-docker.ext4")
|
||||
kernel := d.config.DefaultKernel
|
||||
initrd := d.config.DefaultInitrd
|
||||
if !exists(rootfs) || !exists(kernel) {
|
||||
return nil
|
||||
}
|
||||
id, err := model.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := model.Now()
|
||||
image := model.Image{
|
||||
ID: id,
|
||||
Name: d.config.DefaultImageName,
|
||||
Managed: false,
|
||||
RootfsPath: rootfs,
|
||||
KernelPath: kernel,
|
||||
InitrdPath: initrd,
|
||||
ModulesDir: d.config.DefaultModulesDir,
|
||||
PackagesPath: d.config.DefaultPackagesFile,
|
||||
Docker: strings.Contains(filepath.Base(rootfs), "docker"),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
return d.store.UpsertImage(ctx, image)
|
||||
}
|
||||
|
||||
func (d *Daemon) reconcile(ctx context.Context) error {
|
||||
vms, err := d.store.ListVMs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, vm := range vms {
|
||||
if vm.State != model.VMStateRunning {
|
||||
continue
|
||||
}
|
||||
if system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
continue
|
||||
}
|
||||
_ = d.cleanupRuntime(ctx, vm, true)
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
vm.Runtime.PID = 0
|
||||
vm.Runtime.TapDevice = ""
|
||||
vm.Runtime.APISockPath = ""
|
||||
vm.Runtime.BaseLoop = ""
|
||||
vm.Runtime.COWLoop = ""
|
||||
vm.Runtime.DMName = ""
|
||||
vm.Runtime.DMDev = ""
|
||||
vm.UpdatedAt = model.Now()
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) FindVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
if idOrName == "" {
|
||||
return model.VMRecord{}, errors.New("vm id or name is required")
|
||||
}
|
||||
if vm, err := d.store.GetVM(ctx, idOrName); err == nil {
|
||||
return vm, nil
|
||||
}
|
||||
vms, err := d.store.ListVMs(ctx)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
matchCount := 0
|
||||
var match model.VMRecord
|
||||
for _, vm := range vms {
|
||||
if strings.HasPrefix(vm.ID, idOrName) || strings.HasPrefix(vm.Name, idOrName) {
|
||||
match = vm
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
if matchCount == 1 {
|
||||
return match, nil
|
||||
}
|
||||
if matchCount > 1 {
|
||||
return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", idOrName)
|
||||
}
|
||||
return model.VMRecord{}, fmt.Errorf("vm %q not found", idOrName)
|
||||
}
|
||||
|
||||
func (d *Daemon) FindImage(ctx context.Context, idOrName string) (model.Image, error) {
|
||||
if idOrName == "" {
|
||||
return model.Image{}, errors.New("image id or name is required")
|
||||
}
|
||||
if image, err := d.store.GetImageByName(ctx, idOrName); err == nil {
|
||||
return image, nil
|
||||
}
|
||||
if image, err := d.store.GetImageByID(ctx, idOrName); err == nil {
|
||||
return image, nil
|
||||
}
|
||||
images, err := d.store.ListImages(ctx)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
matchCount := 0
|
||||
var match model.Image
|
||||
for _, image := range images {
|
||||
if strings.HasPrefix(image.ID, idOrName) || strings.HasPrefix(image.Name, idOrName) {
|
||||
match = image
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
if matchCount == 1 {
|
||||
return match, nil
|
||||
}
|
||||
if matchCount > 1 {
|
||||
return model.Image{}, fmt.Errorf("multiple images match %q", idOrName)
|
||||
}
|
||||
return model.Image{}, fmt.Errorf("image %q not found", idOrName)
|
||||
}
|
||||
|
||||
func (d *Daemon) TouchVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vm, err := d.FindVM(ctx, idOrName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
system.TouchNow(&vm)
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
func marshalResultOrError(v any, err error) rpc.Response {
|
||||
if err != nil {
|
||||
return rpc.NewError("operation_failed", err.Error())
|
||||
}
|
||||
resp, marshalErr := rpc.NewResult(v)
|
||||
if marshalErr != nil {
|
||||
return rpc.NewError("marshal_failed", marshalErr.Error())
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue