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:
Thales Maciel 2026-03-16 12:52:54 -03:00
parent 3cf33d1e0a
commit ea72ea26fe
No known key found for this signature in database
GPG key ID: 33112E6833C34679
22 changed files with 5480 additions and 0 deletions

53
Makefile Normal file
View file

@ -0,0 +1,53 @@
SHELL := /usr/bin/env bash
GO ?= go
GOFMT ?= gofmt
INSTALL ?= install
PREFIX ?= $(HOME)/.local
BINDIR ?= $(PREFIX)/bin
DESTDIR ?=
BINARIES := banger bangerd
GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort)
.DEFAULT_GOAL := help
.PHONY: help build banger bangerd test fmt tidy clean rootfs install
help:
@printf '%s\n' \
'Targets:' \
' make build Build ./banger and ./bangerd' \
' make install Build and install binaries into $(DESTDIR)$(BINDIR)' \
' make test Run go test ./...' \
' make fmt Format Go sources under cmd/ and internal/' \
' make tidy Run go mod tidy' \
' make clean Remove built Go binaries' \
' make rootfs Run ./make-rootfs.sh'
build: $(BINARIES)
banger: $(GO_SOURCES) go.mod go.sum
$(GO) build -o ./banger ./cmd/banger
bangerd: $(GO_SOURCES) go.mod go.sum
$(GO) build -o ./bangerd ./cmd/bangerd
test:
$(GO) test ./...
fmt:
$(GOFMT) -w $(GO_SOURCES)
tidy:
$(GO) mod tidy
clean:
rm -f ./banger ./bangerd
install: build
mkdir -p "$(DESTDIR)$(BINDIR)"
$(INSTALL) -m 0755 ./banger "$(DESTDIR)$(BINDIR)/banger"
$(INSTALL) -m 0755 ./bangerd "$(DESTDIR)$(BINDIR)/bangerd"
rootfs:
./make-rootfs.sh

View file

@ -24,6 +24,39 @@ Minimal Firecracker launcher.
./run.sh
```
## Experimental Go Control Plane
There is now an XDG-based Go daemon + CLI prototype alongside the shell scripts.
It keeps persistent VM/image state in SQLite under your XDG state directory and
talks over a Unix socket under your XDG runtime directory.
Build it with:
```
make build
```
Or directly with Go:
```
go build -o ./banger ./cmd/banger
go build -o ./bangerd ./cmd/bangerd
```
Basic usage:
```
./banger daemon status
./banger tui
./banger vm list
./banger vm create --name calm-otter --disk-size 16G
./banger vm set calm-otter --memory 2048 --vcpu 4
./banger image list
```
Notes:
- `banger` auto-starts the per-user daemon when needed.
- `banger tui` launches a terminal UI for browsing, creating, editing, and operating VMs.
- VM configs are persistent by default.
- RAM, vCPU, and work-disk size edits are stopped-only.
- The Go image build path currently delegates guest customization to `customize.sh`.
## Run Options
```
./run.sh --name calm-otter --vcpu 4 --ram 2048 --overlay-size 12G

22
cmd/banger/main.go Normal file
View file

@ -0,0 +1,22 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"banger/internal/cli"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cmd := cli.NewBangerCommand()
if err := cmd.ExecuteContext(ctx); err != nil {
fmt.Fprintf(os.Stderr, "banger: %v\n", err)
os.Exit(1)
}
}

22
cmd/bangerd/main.go Normal file
View file

@ -0,0 +1,22 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"banger/internal/cli"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cmd := cli.NewBangerdCommand()
if err := cmd.ExecuteContext(ctx); err != nil {
fmt.Fprintf(os.Stderr, "bangerd: %v\n", err)
os.Exit(1)
}
}

37
go.mod Normal file
View file

@ -0,0 +1,37 @@
module banger
go 1.25.0
require (
github.com/charmbracelet/bubbles v0.14.0
github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873
github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43
github.com/mattn/go-isatty v0.0.20
github.com/pelletier/go-toml v1.9.5
github.com/spf13/cobra v1.8.1
modernc.org/sqlite v1.38.2
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.1 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

104
go.sum Normal file
View file

@ -0,0 +1,104 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbles v0.14.0 h1:DJfCwnARfWjZLvMglhSQzo76UZ2gucuHPy9jLWX45Og=
github.com/charmbracelet/bubbles v0.14.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873 h1:ti/1QRoSzanYHPW4jLgIjCkfJ3beXh2h1nr6nEkWOig=
github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43 h1:xO5Bh21Ii+0p3EYp1GdFEF/Iax7VhBgMbBVCOFBZ2/Q=
github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

81
internal/api/types.go Normal file
View file

@ -0,0 +1,81 @@
package api
import "banger/internal/model"
type Empty struct{}
type PingResult struct {
Status string `json:"status"`
PID int `json:"pid"`
}
type ShutdownResult struct {
Status string `json:"status"`
}
type VMCreateParams struct {
Name string `json:"name,omitempty"`
ImageName string `json:"image_name,omitempty"`
VCPUCount int `json:"vcpu_count,omitempty"`
MemoryMiB int `json:"memory_mib,omitempty"`
SystemOverlaySize string `json:"system_overlay_size,omitempty"`
WorkDiskSize string `json:"work_disk_size,omitempty"`
NATEnabled bool `json:"nat_enabled,omitempty"`
NoStart bool `json:"no_start,omitempty"`
}
type VMRefParams struct {
IDOrName string `json:"id_or_name"`
}
type VMSetParams struct {
IDOrName string `json:"id_or_name"`
VCPUCount *int `json:"vcpu_count,omitempty"`
MemoryMiB *int `json:"memory_mib,omitempty"`
WorkDiskSize string `json:"work_disk_size,omitempty"`
NATEnabled *bool `json:"nat_enabled,omitempty"`
}
type VMListResult struct {
VMs []model.VMRecord `json:"vms"`
}
type VMShowResult struct {
VM model.VMRecord `json:"vm"`
}
type VMStatsResult struct {
VM model.VMRecord `json:"vm"`
Stats model.VMStats `json:"stats"`
}
type VMLogsResult struct {
LogPath string `json:"log_path"`
}
type VMSSHResult struct {
Name string `json:"name"`
GuestIP string `json:"guest_ip"`
}
type ImageBuildParams struct {
Name string `json:"name,omitempty"`
BaseRootfs string `json:"base_rootfs,omitempty"`
Size string `json:"size,omitempty"`
KernelPath string `json:"kernel_path,omitempty"`
InitrdPath string `json:"initrd_path,omitempty"`
ModulesDir string `json:"modules_dir,omitempty"`
Docker bool `json:"docker,omitempty"`
}
type ImageRefParams struct {
IDOrName string `json:"id_or_name"`
}
type ImageListResult struct {
Images []model.Image `json:"images"`
}
type ImageShowResult struct {
Image model.Image `json:"image"`
}

708
internal/cli/banger.go Normal file
View file

@ -0,0 +1,708 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"text/tabwriter"
"time"
"banger/internal/api"
"banger/internal/config"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/rpc"
"banger/internal/system"
"github.com/spf13/cobra"
)
var (
bangerdPathFunc = paths.BangerdPath
daemonExePath = func(pid int) string {
return filepath.Join("/proc", fmt.Sprintf("%d", pid), "exe")
}
)
func NewBangerCommand() *cobra.Command {
root := &cobra.Command{
Use: "banger",
Short: "Manage development VMs and images",
SilenceUsage: true,
SilenceErrors: true,
RunE: helpNoArgs,
}
root.CompletionOptions.DisableDefaultCmd = true
root.AddCommand(newDaemonCommand(), newVMCommand(), newImageCommand(), newTUICommand())
return root
}
func newDaemonCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "daemon",
Short: "Manage the banger daemon",
RunE: helpNoArgs,
}
cmd.AddCommand(
&cobra.Command{
Use: "status",
Short: "Show daemon status",
Args: noArgsUsage("usage: banger daemon status"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, err := paths.Resolve()
if err != nil {
return err
}
ping, pingErr := rpc.Call[api.PingResult](cmd.Context(), layout.SocketPath, "ping", api.Empty{})
if pingErr != nil {
_, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\n", layout.SocketPath)
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\n", ping.PID, layout.SocketPath)
return err
},
},
&cobra.Command{
Use: "stop",
Short: "Stop the daemon",
Args: noArgsUsage("usage: banger daemon stop"),
RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, err := paths.Resolve()
if err != nil {
return err
}
_, err = rpc.Call[api.ShutdownResult](cmd.Context(), layout.SocketPath, "shutdown", api.Empty{})
if err != nil {
if os.IsNotExist(err) || strings.Contains(err.Error(), "connect") {
_, writeErr := fmt.Fprintln(cmd.OutOrStdout(), "daemon not running")
return writeErr
}
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), "stopping")
return err
},
},
&cobra.Command{
Use: "socket",
Short: "Print the daemon socket path",
Args: noArgsUsage("usage: banger daemon socket"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, err := paths.Resolve()
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), layout.SocketPath)
return err
},
},
)
return cmd
}
func newVMCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "vm",
Short: "Manage virtual machines",
RunE: helpNoArgs,
}
cmd.AddCommand(
newVMCreateCommand(),
newVMListCommand(),
newVMShowCommand(),
newVMActionCommand("start", "Start a VM", "vm.start"),
newVMActionCommand("stop", "Stop a VM", "vm.stop"),
newVMActionCommand("restart", "Restart a VM", "vm.restart"),
newVMActionCommand("delete", "Delete a VM", "vm.delete"),
newVMSetCommand(),
newVMSSHCommand(),
newVMLogsCommand(),
newVMStatsCommand(),
)
return cmd
}
func newVMCreateCommand() *cobra.Command {
var params api.VMCreateParams
cmd := &cobra.Command{
Use: "create",
Short: "Create a VM",
Args: noArgsUsage("usage: banger vm create"),
RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.create", params)
if err != nil {
return err
}
return printVMSummary(cmd.OutOrStdout(), result.VM)
},
}
cmd.Flags().StringVar(&params.Name, "name", "", "vm name")
cmd.Flags().StringVar(&params.ImageName, "image", "", "image name or id")
cmd.Flags().IntVar(&params.VCPUCount, "vcpu", 0, "vcpu count")
cmd.Flags().IntVar(&params.MemoryMiB, "memory", 0, "memory in MiB")
cmd.Flags().StringVar(&params.SystemOverlaySize, "system-overlay-size", "", "system overlay size")
cmd.Flags().StringVar(&params.WorkDiskSize, "disk-size", "", "work disk size")
cmd.Flags().BoolVar(&params.NATEnabled, "nat", false, "enable NAT")
cmd.Flags().BoolVar(&params.NoStart, "no-start", false, "create without starting")
return cmd
}
func newVMListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List VMs",
Args: noArgsUsage("usage: banger vm list"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMListResult](cmd.Context(), layout.SocketPath, "vm.list", api.Empty{})
if err != nil {
return err
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 8, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tCREATED")
for _, vm := range result.VMs {
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%d\t%d MiB\t%s\t%s\n",
shortID(vm.ID),
vm.Name,
vm.State,
shortID(vm.ImageID),
vm.Runtime.GuestIP,
vm.Spec.VCPUCount,
vm.Spec.MemoryMiB,
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
relativeTime(vm.CreatedAt),
)
}
return w.Flush()
},
}
}
func newVMShowCommand() *cobra.Command {
return &cobra.Command{
Use: "show <id-or-name>",
Short: "Show VM details",
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.VM)
},
}
}
func newVMActionCommand(use, short, method string) *cobra.Command {
return &cobra.Command{
Use: use + " <id-or-name>",
Short: short,
Args: exactArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>", use)),
RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, method, api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printVMSummary(cmd.OutOrStdout(), result.VM)
},
}
}
func newVMSetCommand() *cobra.Command {
var (
vcpu int
memory int
diskSize string
nat bool
noNat bool
)
cmd := &cobra.Command{
Use: "set <id-or-name>",
Short: "Update stopped VM settings",
Args: exactArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>"),
RunE: func(cmd *cobra.Command, args []string) error {
params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
if err != nil {
return err
}
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.set", params)
if err != nil {
return err
}
return printVMSummary(cmd.OutOrStdout(), result.VM)
},
}
cmd.Flags().IntVar(&vcpu, "vcpu", -1, "vcpu count")
cmd.Flags().IntVar(&memory, "memory", -1, "memory in MiB")
cmd.Flags().StringVar(&diskSize, "disk-size", "", "new work disk size")
cmd.Flags().BoolVar(&nat, "nat", false, "enable NAT")
cmd.Flags().BoolVar(&noNat, "no-nat", false, "disable NAT")
return cmd
}
func newVMSSHCommand() *cobra.Command {
return &cobra.Command{
Use: "ssh <id-or-name> [ssh args...]",
Short: "SSH into a running VM",
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, cfg, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMSSHResult](cmd.Context(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
sshArgs, err := sshCommandArgs(cfg, result.GuestIP, args[1:])
if err != nil {
return err
}
sshCmd := exec.CommandContext(cmd.Context(), "ssh", sshArgs...)
sshCmd.Stdout = cmd.OutOrStdout()
sshCmd.Stderr = cmd.ErrOrStderr()
sshCmd.Stdin = cmd.InOrStdin()
return sshCmd.Run()
},
}
}
func newVMLogsCommand() *cobra.Command {
var follow bool
cmd := &cobra.Command{
Use: "logs <id-or-name>",
Short: "Show VM logs",
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMLogsResult](cmd.Context(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
if result.LogPath == "" {
return errors.New("vm has no log path")
}
return system.CopyStream(cmd.OutOrStdout(), system.TailCommand(result.LogPath, follow))
},
}
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow logs")
return cmd
}
func newVMStatsCommand() *cobra.Command {
return &cobra.Command{
Use: "stats <id-or-name>",
Short: "Show VM stats",
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMStatsResult](cmd.Context(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result)
},
}
}
func newImageCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "image",
Short: "Manage images",
RunE: helpNoArgs,
}
cmd.AddCommand(
newImageBuildCommand(),
newImageListCommand(),
newImageShowCommand(),
newImageDeleteCommand(),
)
return cmd
}
func newImageBuildCommand() *cobra.Command {
var params api.ImageBuildParams
cmd := &cobra.Command{
Use: "build",
Short: "Build an image",
Args: noArgsUsage("usage: banger image build"),
RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.build", params)
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
cmd.Flags().StringVar(&params.Name, "name", "", "image name")
cmd.Flags().StringVar(&params.BaseRootfs, "base-rootfs", "", "base rootfs path")
cmd.Flags().StringVar(&params.Size, "size", "", "output image size")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir")
cmd.Flags().BoolVar(&params.Docker, "docker", false, "install docker")
return cmd
}
func newImageListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List images",
Args: noArgsUsage("usage: banger image list"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{})
if err != nil {
return err
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 8, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tMANAGED\tROOTFS\tCREATED")
for _, image := range result.Images {
fmt.Fprintf(w, "%s\t%s\t%t\t%s\t%s\n", shortID(image.ID), image.Name, image.Managed, image.RootfsPath, relativeTime(image.CreatedAt))
}
return w.Flush()
},
}
}
func newImageShowCommand() *cobra.Command {
return &cobra.Command{
Use: "show <id-or-name>",
Short: "Show image details",
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.show", api.ImageRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.Image)
},
}
}
func newImageDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <id-or-name>",
Short: "Delete an image",
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.delete", api.ImageRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
}
func helpNoArgs(cmd *cobra.Command, args []string) error {
if len(args) != 0 {
return fmt.Errorf("unknown arguments: %s", strings.Join(args, " "))
}
return cmd.Help()
}
func noArgsUsage(usage string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != 0 {
return errors.New(usage)
}
return nil
}
}
func exactArgsUsage(n int, usage string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != n {
return errors.New(usage)
}
return nil
}
}
func minArgsUsage(n int, usage string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) < n {
return errors.New(usage)
}
return nil
}
}
func ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) {
layout, err := paths.Resolve()
if err != nil {
return paths.Layout{}, model.DaemonConfig{}, err
}
cfg, err := config.Load(layout)
if err != nil {
return paths.Layout{}, model.DaemonConfig{}, err
}
if ping, err := rpc.Call[api.PingResult](ctx, layout.SocketPath, "ping", api.Empty{}); err == nil {
if daemonOutdated(ping.PID) {
if err := restartDaemon(ctx, layout, ping.PID); err != nil {
return paths.Layout{}, model.DaemonConfig{}, err
}
return layout, cfg, nil
}
return layout, cfg, nil
}
if err := startDaemon(ctx, layout); err != nil {
return paths.Layout{}, model.DaemonConfig{}, err
}
return layout, cfg, nil
}
func daemonOutdated(pid int) bool {
if pid <= 0 {
return false
}
daemonBin, err := bangerdPathFunc()
if err != nil {
return false
}
currentInfo, err := os.Stat(daemonBin)
if err != nil {
return false
}
runningInfo, err := os.Stat(daemonExePath(pid))
if err != nil {
return false
}
return !os.SameFile(currentInfo, runningInfo)
}
func restartDaemon(ctx context.Context, layout paths.Layout, pid int) error {
stopCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, _ = rpc.Call[api.ShutdownResult](stopCtx, layout.SocketPath, "shutdown", api.Empty{})
if waitForPIDExit(pid, 2*time.Second) {
return startDaemon(ctx, layout)
}
if proc, err := os.FindProcess(pid); err == nil {
_ = proc.Signal(syscall.SIGTERM)
}
if !waitForPIDExit(pid, 2*time.Second) {
return fmt.Errorf("timed out restarting stale daemon pid %d", pid)
}
return startDaemon(ctx, layout)
}
func waitForPIDExit(pid int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !pidRunning(pid) {
return true
}
time.Sleep(50 * time.Millisecond)
}
return !pidRunning(pid)
}
func pidRunning(pid int) bool {
if pid <= 0 {
return false
}
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
func startDaemon(ctx context.Context, layout paths.Layout) error {
if err := paths.Ensure(layout); err != nil {
return err
}
logFile, err := os.OpenFile(layout.DaemonLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer logFile.Close()
daemonBin, err := paths.BangerdPath()
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, daemonBin)
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return err
}
if err := rpc.WaitForSocket(layout.SocketPath, 5*time.Second); err != nil {
return fmt.Errorf("daemon failed to start; inspect %s: %w", layout.DaemonLog, err)
}
return nil
}
func vmSetParamsFromFlags(idOrName string, vcpu, memory int, diskSize string, nat, noNat bool) (api.VMSetParams, error) {
if nat && noNat {
return api.VMSetParams{}, errors.New("use only one of --nat or --no-nat")
}
params := api.VMSetParams{IDOrName: idOrName, WorkDiskSize: diskSize}
if vcpu >= 0 {
params.VCPUCount = &vcpu
}
if memory >= 0 {
params.MemoryMiB = &memory
}
if nat || noNat {
value := nat && !noNat
params.NATEnabled = &value
}
if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil {
return api.VMSetParams{}, errors.New("no VM settings changed")
}
return params, nil
}
func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) {
if guestIP == "" {
return nil, errors.New("vm has no guest IP")
}
args := []string{}
if cfg.RepoRoot != "" {
args = append(args, "-i", filepath.Join(cfg.RepoRoot, "id_ed25519"))
}
args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "root@"+guestIP)
args = append(args, extra...)
return args, nil
}
func printJSON(out anyWriter, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(out, string(data))
return err
}
func printVMSummary(out anyWriter, vm model.VMRecord) error {
_, err := fmt.Fprintf(
out,
"%s\t%s\t%s\t%s\t%s\t%s\n",
shortID(vm.ID),
vm.Name,
vm.State,
vm.Runtime.GuestIP,
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
vm.Runtime.DNSName,
)
return err
}
func printImageSummary(out anyWriter, image model.Image) error {
_, err := fmt.Fprintf(out, "%s\t%s\t%t\t%s\n", shortID(image.ID), image.Name, image.Managed, image.RootfsPath)
return err
}
type anyWriter interface {
Write(p []byte) (n int, err error)
}
func shortID(id string) string {
if len(id) <= 12 {
return id
}
return id[:12]
}
func relativeTime(t time.Time) string {
if t.IsZero() {
return "-"
}
delta := time.Since(t)
switch {
case delta < 30*time.Second:
return "moments ago"
case delta < time.Minute:
return fmt.Sprintf("%d seconds ago", int(delta.Seconds()))
case delta < 2*time.Minute:
return "1 minute ago"
case delta < time.Hour:
return fmt.Sprintf("%d minutes ago", int(delta.Minutes()))
case delta < 2*time.Hour:
return "1 hour ago"
case delta < 24*time.Hour:
return fmt.Sprintf("%d hours ago", int(delta.Hours()))
case delta < 48*time.Hour:
return "1 day ago"
case delta < 7*24*time.Hour:
return fmt.Sprintf("%d days ago", int(delta.Hours()/24))
case delta < 14*24*time.Hour:
return "1 week ago"
default:
return fmt.Sprintf("%d weeks ago", int(delta.Hours()/(24*7)))
}
}

27
internal/cli/bangerd.go Normal file
View file

@ -0,0 +1,27 @@
package cli
import (
"banger/internal/daemon"
"github.com/spf13/cobra"
)
func NewBangerdCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "bangerd",
Short: "Run the banger daemon",
SilenceUsage: true,
SilenceErrors: true,
Args: noArgsUsage("usage: bangerd"),
RunE: func(cmd *cobra.Command, args []string) error {
d, err := daemon.Open(cmd.Context())
if err != nil {
return err
}
defer d.Close()
return d.Serve(cmd.Context())
},
}
cmd.CompletionOptions.DisableDefaultCmd = true
return cmd
}

129
internal/cli/cli_test.go Normal file
View file

@ -0,0 +1,129 @@
package cli
import (
"os"
"path/filepath"
"reflect"
"testing"
"banger/internal/model"
)
func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
cmd := NewBangerCommand()
names := []string{}
for _, sub := range cmd.Commands() {
names = append(names, sub.Name())
}
want := []string{"daemon", "image", "tui", "vm"}
if !reflect.DeepEqual(names, want) {
t.Fatalf("subcommands = %v, want %v", names, want)
}
}
func TestVMCreateFlagsExist(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
for _, flagName := range []string{"name", "image", "vcpu", "memory", "system-overlay-size", "disk-size", "nat", "no-start"} {
if create.Flags().Lookup(flagName) == nil {
t.Fatalf("missing flag %q", flagName)
}
}
}
func TestVMSetParamsFromFlags(t *testing.T) {
params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false)
if err != nil {
t.Fatalf("vmSetParamsFromFlags: %v", err)
}
if params.IDOrName != "devbox" || params.VCPUCount == nil || *params.VCPUCount != 4 {
t.Fatalf("unexpected params: %+v", params)
}
if params.MemoryMiB == nil || *params.MemoryMiB != 2048 {
t.Fatalf("unexpected memory: %+v", params)
}
if params.WorkDiskSize != "16G" {
t.Fatalf("unexpected disk size: %+v", params)
}
if params.NATEnabled == nil || !*params.NATEnabled {
t.Fatalf("unexpected nat value: %+v", params)
}
}
func TestVMSetParamsFromFlagsConflict(t *testing.T) {
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
t.Fatal("expected nat conflict error")
}
}
func TestSSHCommandArgs(t *testing.T) {
args, err := sshCommandArgs(model.DaemonConfig{RepoRoot: "/repo"}, "172.16.0.2", []string{"--", "uname", "-a"})
if err != nil {
t.Fatalf("sshCommandArgs: %v", err)
}
want := []string{
"-i", "/repo/id_ed25519",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"root@172.16.0.2",
"--", "uname", "-a",
}
if !reflect.DeepEqual(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestNewBangerdCommandRejectsArgs(t *testing.T) {
cmd := NewBangerdCommand()
cmd.SetArgs([]string{"extra"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected extra args to be rejected")
}
}
func TestDaemonOutdated(t *testing.T) {
dir := t.TempDir()
current := filepath.Join(dir, "bangerd-current")
same := filepath.Join(dir, "bangerd-same")
stale := filepath.Join(dir, "bangerd-stale")
if err := os.WriteFile(current, []byte("current"), 0o755); err != nil {
t.Fatalf("write current: %v", err)
}
if err := os.Link(current, same); err != nil {
t.Fatalf("hard link: %v", err)
}
if err := os.WriteFile(stale, []byte("stale"), 0o755); err != nil {
t.Fatalf("write stale: %v", err)
}
origBangerdPath := bangerdPathFunc
origDaemonExePath := daemonExePath
t.Cleanup(func() {
bangerdPathFunc = origBangerdPath
daemonExePath = origDaemonExePath
})
bangerdPathFunc = func() (string, error) {
return current, nil
}
daemonExePath = func(pid int) string {
if pid == 1 {
return same
}
return stale
}
if daemonOutdated(1) {
t.Fatal("expected matching daemon executable to be current")
}
if !daemonOutdated(2) {
t.Fatal("expected replaced daemon executable to be outdated")
}
}

1385
internal/cli/tui.go Normal file

File diff suppressed because it is too large Load diff

89
internal/cli/tui_test.go Normal file
View file

@ -0,0 +1,89 @@
package cli
import (
"testing"
"banger/internal/model"
)
func TestCreateVMFormSubmit(t *testing.T) {
form := newCreateVMForm([]model.Image{{Name: "default"}}, model.DaemonConfig{DefaultImageName: "default"})
form.fields[0].input.SetValue("devbox")
form.fields[2].input.SetValue("4")
form.fields[3].input.SetValue("2048")
form.fields[4].input.SetValue("12G")
form.fields[5].input.SetValue("24G")
form.fields[6].index = 1
action, err := form.submit()
if err != nil {
t.Fatalf("submit: %v", err)
}
if action.kind != actionCreate {
t.Fatalf("kind = %s, want %s", action.kind, actionCreate)
}
if action.create.Name != "devbox" || action.create.ImageName != "default" {
t.Fatalf("unexpected create params: %+v", action.create)
}
if action.create.VCPUCount != 4 || action.create.MemoryMiB != 2048 {
t.Fatalf("unexpected cpu/memory: %+v", action.create)
}
if action.create.SystemOverlaySize != "12G" || action.create.WorkDiskSize != "24G" {
t.Fatalf("unexpected disk sizes: %+v", action.create)
}
if !action.create.NATEnabled {
t.Fatalf("expected NAT enabled: %+v", action.create)
}
}
func TestEditVMFormSubmit(t *testing.T) {
form := newEditVMForm(model.VMRecord{
ID: "vm-1",
Spec: model.VMSpec{
VCPUCount: 2,
MemoryMiB: 1024,
WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024,
NATEnabled: false,
},
})
form.fields[0].input.SetValue("6")
form.fields[1].input.SetValue("4096")
form.fields[2].input.SetValue("32G")
form.fields[3].index = 1
action, err := form.submit()
if err != nil {
t.Fatalf("submit: %v", err)
}
if action.kind != actionEdit {
t.Fatalf("kind = %s, want %s", action.kind, actionEdit)
}
if action.set.IDOrName != "vm-1" {
t.Fatalf("unexpected vm id: %+v", action.set)
}
if action.set.VCPUCount == nil || *action.set.VCPUCount != 6 {
t.Fatalf("unexpected vcpu: %+v", action.set)
}
if action.set.MemoryMiB == nil || *action.set.MemoryMiB != 4096 {
t.Fatalf("unexpected memory: %+v", action.set)
}
if action.set.WorkDiskSize != "32G" {
t.Fatalf("unexpected disk size: %+v", action.set)
}
if action.set.NATEnabled == nil || !*action.set.NATEnabled {
t.Fatalf("expected nat enabled: %+v", action.set)
}
}
func TestResolveSelectedID(t *testing.T) {
vms := []model.VMRecord{{ID: "one"}, {ID: "two"}}
if got := resolveSelectedID("two", vms); got != "two" {
t.Fatalf("resolveSelectedID existing = %q, want %q", got, "two")
}
if got := resolveSelectedID("missing", vms); got != "one" {
t.Fatalf("resolveSelectedID fallback = %q, want %q", got, "one")
}
if got := resolveSelectedID("anything", nil); got != "" {
t.Fatalf("resolveSelectedID empty = %q, want empty", got)
}
}

125
internal/config/config.go Normal file
View file

@ -0,0 +1,125 @@
package config
import (
"os"
"path/filepath"
"time"
toml "github.com/pelletier/go-toml"
"banger/internal/model"
"banger/internal/paths"
)
type fileConfig struct {
RepoRoot string `toml:"repo_root"`
DefaultImageName string `toml:"default_image_name"`
DefaultBaseRootfs string `toml:"default_base_rootfs"`
DefaultKernel string `toml:"default_kernel"`
DefaultInitrd string `toml:"default_initrd"`
DefaultModulesDir string `toml:"default_modules_dir"`
DefaultPackages string `toml:"default_packages_file"`
AutoStopStaleAfter string `toml:"auto_stop_stale_after"`
StatsPollInterval string `toml:"stats_poll_interval"`
MetricsPoll string `toml:"metrics_poll_interval"`
BridgeName string `toml:"bridge_name"`
BridgeIP string `toml:"bridge_ip"`
CIDR string `toml:"cidr"`
DefaultDNS string `toml:"default_dns"`
}
func Load(layout paths.Layout) (model.DaemonConfig, error) {
cfg := model.DaemonConfig{
RepoRoot: paths.DetectRepoRoot(),
AutoStopStaleAfter: 0,
StatsPollInterval: model.DefaultStatsPollInterval,
MetricsPollInterval: model.DefaultMetricsPollInterval,
BridgeName: model.DefaultBridgeName,
BridgeIP: model.DefaultBridgeIP,
CIDR: model.DefaultCIDR,
DefaultDNS: model.DefaultDNS,
DefaultImageName: "default",
}
if cfg.RepoRoot != "" {
cfg.DefaultBaseRootfs = filepath.Join(cfg.RepoRoot, "rootfs.ext4")
cfg.DefaultKernel = filepath.Join(cfg.RepoRoot, "wtf/root/boot/vmlinux-6.8.0-94-generic")
cfg.DefaultInitrd = filepath.Join(cfg.RepoRoot, "wtf/root/boot/initrd.img-6.8.0-94-generic")
cfg.DefaultModulesDir = filepath.Join(cfg.RepoRoot, "wtf/root/lib/modules/6.8.0-94-generic")
cfg.DefaultPackagesFile = filepath.Join(cfg.RepoRoot, "packages.apt")
}
path := filepath.Join(layout.ConfigDir, "config.toml")
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
if info.IsDir() {
return cfg, nil
}
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
var file fileConfig
if err := toml.Unmarshal(data, &file); err != nil {
return cfg, err
}
if file.RepoRoot != "" {
cfg.RepoRoot = file.RepoRoot
}
if file.DefaultImageName != "" {
cfg.DefaultImageName = file.DefaultImageName
}
if file.DefaultBaseRootfs != "" {
cfg.DefaultBaseRootfs = file.DefaultBaseRootfs
}
if file.DefaultKernel != "" {
cfg.DefaultKernel = file.DefaultKernel
}
if file.DefaultInitrd != "" {
cfg.DefaultInitrd = file.DefaultInitrd
}
if file.DefaultModulesDir != "" {
cfg.DefaultModulesDir = file.DefaultModulesDir
}
if file.DefaultPackages != "" {
cfg.DefaultPackagesFile = file.DefaultPackages
}
if file.BridgeName != "" {
cfg.BridgeName = file.BridgeName
}
if file.BridgeIP != "" {
cfg.BridgeIP = file.BridgeIP
}
if file.CIDR != "" {
cfg.CIDR = file.CIDR
}
if file.DefaultDNS != "" {
cfg.DefaultDNS = file.DefaultDNS
}
if file.AutoStopStaleAfter != "" {
duration, err := time.ParseDuration(file.AutoStopStaleAfter)
if err != nil {
return cfg, err
}
cfg.AutoStopStaleAfter = duration
}
if file.StatsPollInterval != "" {
duration, err := time.ParseDuration(file.StatsPollInterval)
if err != nil {
return cfg, err
}
cfg.StatsPollInterval = duration
}
if file.MetricsPoll != "" {
duration, err := time.ParseDuration(file.MetricsPoll)
if err != nil {
return cfg, err
}
cfg.MetricsPollInterval = duration
}
return cfg, nil
}

420
internal/daemon/daemon.go Normal file
View 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
}

131
internal/daemon/images.go Normal file
View file

@ -0,0 +1,131 @@
package daemon
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"banger/internal/api"
"banger/internal/model"
)
func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (model.Image, error) {
d.mu.Lock()
defer d.mu.Unlock()
name := params.Name
if name == "" {
name = fmt.Sprintf("image-%d", model.Now().Unix())
}
if _, err := d.FindImage(ctx, name); err == nil {
return model.Image{}, fmt.Errorf("image name already exists: %s", name)
}
if d.config.RepoRoot == "" {
return model.Image{}, fmt.Errorf("repo root not found; set repo_root in config.toml")
}
baseRootfs := params.BaseRootfs
if baseRootfs == "" {
baseRootfs = d.config.DefaultBaseRootfs
}
if baseRootfs == "" {
return model.Image{}, fmt.Errorf("base rootfs is required")
}
id, err := model.NewID()
if err != nil {
return model.Image{}, err
}
now := model.Now()
artifactDir := filepath.Join(d.layout.ImagesDir, id)
if err := os.MkdirAll(artifactDir, 0o755); err != nil {
return model.Image{}, err
}
rootfsPath := filepath.Join(artifactDir, "rootfs.ext4")
script := filepath.Join(d.config.RepoRoot, "customize.sh")
if _, err := os.Stat(script); err != nil {
return model.Image{}, fmt.Errorf("customize.sh not found at %s", script)
}
args := []string{script, baseRootfs, "--out", rootfsPath}
if params.Size != "" {
args = append(args, "--size", params.Size)
}
kernelPath := params.KernelPath
if kernelPath == "" {
kernelPath = d.config.DefaultKernel
}
if kernelPath != "" {
args = append(args, "--kernel", kernelPath)
}
initrdPath := params.InitrdPath
if initrdPath == "" {
initrdPath = d.config.DefaultInitrd
}
if initrdPath != "" {
args = append(args, "--initrd", initrdPath)
}
modulesDir := params.ModulesDir
if modulesDir == "" {
modulesDir = d.config.DefaultModulesDir
}
if modulesDir != "" {
args = append(args, "--modules", modulesDir)
}
if params.Docker {
args = append(args, "--docker")
}
cmd := exec.CommandContext(ctx, "bash", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Dir = d.config.RepoRoot
if err := cmd.Run(); err != nil {
_ = os.RemoveAll(artifactDir)
return model.Image{}, err
}
image := model.Image{
ID: id,
Name: name,
Managed: true,
ArtifactDir: artifactDir,
RootfsPath: rootfsPath,
KernelPath: kernelPath,
InitrdPath: initrdPath,
ModulesDir: modulesDir,
PackagesPath: d.config.DefaultPackagesFile,
BuildSize: params.Size,
Docker: params.Docker,
CreatedAt: now,
UpdatedAt: now,
}
if err := d.store.UpsertImage(ctx, image); err != nil {
return model.Image{}, err
}
return image, nil
}
func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image, error) {
d.mu.Lock()
defer d.mu.Unlock()
image, err := d.FindImage(ctx, idOrName)
if err != nil {
return model.Image{}, err
}
vms, err := d.store.FindVMsUsingImage(ctx, image.ID)
if err != nil {
return model.Image{}, err
}
if len(vms) > 0 {
return model.Image{}, fmt.Errorf("image %s is still referenced by %d VM(s)", image.Name, len(vms))
}
if err := d.store.DeleteImage(ctx, image.ID); err != nil {
return model.Image{}, err
}
if image.Managed && image.ArtifactDir != "" {
if err := os.RemoveAll(image.ArtifactDir); err != nil {
return model.Image{}, err
}
}
return image, nil
}

845
internal/daemon/vm.go Normal file
View file

@ -0,0 +1,845 @@
package daemon
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"banger/internal/api"
"banger/internal/firecracker"
"banger/internal/model"
"banger/internal/system"
)
func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (model.VMRecord, error) {
d.mu.Lock()
defer d.mu.Unlock()
imageName := params.ImageName
if imageName == "" {
imageName = d.config.DefaultImageName
}
image, err := d.FindImage(ctx, imageName)
if err != nil {
return model.VMRecord{}, err
}
name := strings.TrimSpace(params.Name)
if name == "" {
name, err = d.generateName(ctx)
if err != nil {
return model.VMRecord{}, err
}
}
if _, err := d.FindVM(ctx, name); err == nil {
return model.VMRecord{}, fmt.Errorf("vm name already exists: %s", name)
}
id, err := model.NewID()
if err != nil {
return model.VMRecord{}, err
}
guestIP, err := d.store.NextGuestIP(ctx, bridgePrefix(d.config.BridgeIP))
if err != nil {
return model.VMRecord{}, err
}
vmDir := filepath.Join(d.layout.VMsDir, id)
if err := os.MkdirAll(vmDir, 0o755); err != nil {
return model.VMRecord{}, err
}
systemOverlaySize := int64(model.DefaultSystemOverlaySize)
if params.SystemOverlaySize != "" {
systemOverlaySize, err = model.ParseSize(params.SystemOverlaySize)
if err != nil {
return model.VMRecord{}, err
}
}
workDiskSize := int64(model.DefaultWorkDiskSize)
if params.WorkDiskSize != "" {
workDiskSize, err = model.ParseSize(params.WorkDiskSize)
if err != nil {
return model.VMRecord{}, err
}
}
now := model.Now()
spec := model.VMSpec{
VCPUCount: defaultInt(params.VCPUCount, model.DefaultVCPUCount),
MemoryMiB: defaultInt(params.MemoryMiB, model.DefaultMemoryMiB),
SystemOverlaySizeByte: systemOverlaySize,
WorkDiskSizeBytes: workDiskSize,
NATEnabled: params.NATEnabled,
}
vm := model.VMRecord{
ID: id,
Name: name,
ImageID: image.ID,
State: model.VMStateCreated,
CreatedAt: now,
UpdatedAt: now,
LastTouchedAt: now,
Spec: spec,
Runtime: model.VMRuntime{
State: model.VMStateCreated,
GuestIP: guestIP,
DNSName: name + ".vm",
VMDir: vmDir,
SystemOverlay: filepath.Join(vmDir, "system.cow"),
WorkDiskPath: filepath.Join(vmDir, "root.ext4"),
LogPath: filepath.Join(vmDir, "firecracker.log"),
MetricsPath: filepath.Join(vmDir, "metrics.json"),
},
}
if err := d.store.UpsertVM(ctx, vm); err != nil {
return model.VMRecord{}, err
}
if params.NoStart {
vm.State = model.VMStateStopped
vm.Runtime.State = model.VMStateStopped
if err := d.store.UpsertVM(ctx, vm); err != nil {
return model.VMRecord{}, err
}
return vm, nil
}
return d.startVMLocked(ctx, vm, image)
}
func (d *Daemon) StartVM(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
}
image, err := d.store.GetImageByID(ctx, vm.ImageID)
if err != nil {
return model.VMRecord{}, err
}
if vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
return vm, nil
}
return d.startVMLocked(ctx, vm, image)
}
func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image model.Image) (model.VMRecord, error) {
if err := d.requireStartPrereqs(ctx); err != nil {
return model.VMRecord{}, err
}
if err := os.MkdirAll(vm.Runtime.VMDir, 0o755); err != nil {
return model.VMRecord{}, err
}
if err := d.cleanupRuntime(ctx, vm, true); err != nil {
return model.VMRecord{}, err
}
clearRuntimeHandles(&vm)
if err := d.ensureBridge(ctx); err != nil {
return model.VMRecord{}, err
}
if err := d.ensureSocketDir(); err != nil {
return model.VMRecord{}, err
}
shortID := system.ShortID(vm.ID)
apiSock := filepath.Join(d.layout.RuntimeDir, "fc-"+shortID+".sock")
tap := "tap-fc-" + shortID
dmName := "fc-rootfs-" + shortID
if err := os.RemoveAll(apiSock); err != nil && !os.IsNotExist(err) {
return model.VMRecord{}, err
}
if err := d.ensureSystemOverlay(ctx, &vm); err != nil {
return model.VMRecord{}, err
}
baseLoop, cowLoop, dmDev, err := d.createDMSnapshot(ctx, image.RootfsPath, vm.Runtime.SystemOverlay, dmName)
if err != nil {
return model.VMRecord{}, err
}
vm.Runtime.BaseLoop = baseLoop
vm.Runtime.COWLoop = cowLoop
vm.Runtime.DMName = dmName
vm.Runtime.DMDev = dmDev
vm.Runtime.APISockPath = apiSock
vm.Runtime.TapDevice = tap
vm.Runtime.State = model.VMStateRunning
vm.State = model.VMStateRunning
vm.Runtime.LastError = ""
cleanupOnErr := func(err error) (model.VMRecord, error) {
vm.State = model.VMStateError
vm.Runtime.State = model.VMStateError
vm.Runtime.LastError = err.Error()
_ = d.cleanupRuntime(context.Background(), vm, true)
clearRuntimeHandles(&vm)
_ = d.store.UpsertVM(context.Background(), vm)
return model.VMRecord{}, err
}
if err := d.patchRootOverlay(ctx, vm, image); err != nil {
return cleanupOnErr(err)
}
if err := d.ensureWorkDisk(ctx, &vm); err != nil {
return cleanupOnErr(err)
}
if err := d.createTap(ctx, tap); err != nil {
return cleanupOnErr(err)
}
if err := os.WriteFile(vm.Runtime.MetricsPath, nil, 0o644); err != nil {
return cleanupOnErr(err)
}
fcPath, err := d.firecrackerBinary()
if err != nil {
return cleanupOnErr(err)
}
pid, err := d.startFirecrackerProcess(ctx, fcPath, apiSock, vm.Runtime.LogPath)
if err != nil {
return cleanupOnErr(err)
}
vm.Runtime.PID = pid
if err := d.waitForSocket(ctx, apiSock); err != nil {
return cleanupOnErr(err)
}
if actualPID, err := d.findFirecrackerPID(ctx, apiSock); err == nil && actualPID > 0 {
vm.Runtime.PID = actualPID
}
client := firecracker.New(apiSock)
if err := client.Put(ctx, "/machine-config", map[string]any{
"vcpu_count": vm.Spec.VCPUCount,
"mem_size_mib": vm.Spec.MemoryMiB,
"smt": false,
}); err != nil {
return cleanupOnErr(err)
}
if err := client.Put(ctx, "/metrics", map[string]any{
"metrics_path": vm.Runtime.MetricsPath,
}); err != nil {
return cleanupOnErr(err)
}
boot := map[string]any{
"kernel_image_path": image.KernelPath,
"boot_args": system.BuildBootArgs(vm.Name, vm.Runtime.GuestIP, d.config.BridgeIP, d.config.DefaultDNS),
}
if image.InitrdPath != "" {
boot["initrd_path"] = image.InitrdPath
}
if err := client.Put(ctx, "/boot-source", boot); err != nil {
return cleanupOnErr(err)
}
if err := client.Put(ctx, "/drives/rootfs", map[string]any{
"drive_id": "rootfs",
"path_on_host": vm.Runtime.DMDev,
"is_root_device": true,
"is_read_only": false,
}); err != nil {
return cleanupOnErr(err)
}
if err := client.Put(ctx, "/drives/work", map[string]any{
"drive_id": "work",
"path_on_host": vm.Runtime.WorkDiskPath,
"is_root_device": false,
"is_read_only": false,
}); err != nil {
return cleanupOnErr(err)
}
if err := client.Put(ctx, "/network-interfaces/eth0", map[string]any{
"iface_id": "eth0",
"host_dev_name": tap,
}); err != nil {
return cleanupOnErr(err)
}
if err := client.Put(ctx, "/actions", map[string]any{"action_type": "InstanceStart"}); err != nil {
return cleanupOnErr(err)
}
fcConfig, _ := client.GetConfig(ctx)
vm.Runtime.FirecrackerState = fcConfig
if err := d.setDNS(ctx, vm.Name, vm.Runtime.GuestIP); err != nil {
return cleanupOnErr(err)
}
if vm.Spec.NATEnabled {
if err := d.ensureNAT(ctx, vm, true); err != nil {
return cleanupOnErr(err)
}
}
system.TouchNow(&vm)
if err := d.store.UpsertVM(ctx, vm); err != nil {
return cleanupOnErr(err)
}
return vm, nil
}
func (d *Daemon) StopVM(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
}
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
if err := d.cleanupRuntime(ctx, vm, true); err != nil {
return model.VMRecord{}, err
}
vm.State = model.VMStateStopped
vm.Runtime.State = model.VMStateStopped
clearRuntimeHandles(&vm)
if err := d.store.UpsertVM(ctx, vm); err != nil {
return model.VMRecord{}, err
}
return vm, nil
}
if err := d.sendCtrlAltDel(ctx, vm); err != nil {
return model.VMRecord{}, err
}
if err := d.waitForExit(ctx, vm.Runtime.PID, vm.Runtime.APISockPath, 30*time.Second); err != nil {
return model.VMRecord{}, err
}
if err := d.cleanupRuntime(ctx, vm, true); err != nil {
return model.VMRecord{}, err
}
vm.State = model.VMStateStopped
vm.Runtime.State = model.VMStateStopped
clearRuntimeHandles(&vm)
system.TouchNow(&vm)
if err := d.store.UpsertVM(ctx, vm); err != nil {
return model.VMRecord{}, err
}
return vm, nil
}
func (d *Daemon) RestartVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
vm, err := d.StopVM(ctx, idOrName)
if err != nil {
return model.VMRecord{}, err
}
return d.StartVM(ctx, vm.ID)
}
func (d *Daemon) DeleteVM(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
}
if vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
_ = d.killVMProcess(ctx, vm.Runtime.PID)
}
if err := d.cleanupRuntime(ctx, vm, false); err != nil {
return model.VMRecord{}, err
}
if vm.Spec.NATEnabled {
_ = d.ensureNAT(ctx, vm, false)
}
_ = d.removeDNS(ctx, vm.Runtime.DNSName)
if err := d.store.DeleteVM(ctx, vm.ID); err != nil {
return model.VMRecord{}, err
}
if vm.Runtime.VMDir != "" {
if err := os.RemoveAll(vm.Runtime.VMDir); err != nil {
return model.VMRecord{}, err
}
}
return vm, nil
}
func (d *Daemon) SetVM(ctx context.Context, params api.VMSetParams) (model.VMRecord, error) {
d.mu.Lock()
defer d.mu.Unlock()
vm, err := d.FindVM(ctx, params.IDOrName)
if err != nil {
return model.VMRecord{}, err
}
running := vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath)
if params.VCPUCount != nil {
if running {
return model.VMRecord{}, errors.New("vcpu changes require the VM to be stopped")
}
vm.Spec.VCPUCount = *params.VCPUCount
}
if params.MemoryMiB != nil {
if running {
return model.VMRecord{}, errors.New("memory changes require the VM to be stopped")
}
vm.Spec.MemoryMiB = *params.MemoryMiB
}
if params.WorkDiskSize != "" {
size, err := model.ParseSize(params.WorkDiskSize)
if err != nil {
return model.VMRecord{}, err
}
if running {
return model.VMRecord{}, errors.New("disk changes require the VM to be stopped")
}
if size < vm.Spec.WorkDiskSizeBytes {
return model.VMRecord{}, errors.New("disk size can only grow")
}
if size > vm.Spec.WorkDiskSizeBytes {
if exists(vm.Runtime.WorkDiskPath) {
if err := system.ResizeExt4Image(ctx, d.runner, vm.Runtime.WorkDiskPath, size); err != nil {
return model.VMRecord{}, err
}
}
vm.Spec.WorkDiskSizeBytes = size
}
}
if params.NATEnabled != nil {
vm.Spec.NATEnabled = *params.NATEnabled
if running {
if err := d.ensureNAT(ctx, vm, *params.NATEnabled); 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 (d *Daemon) GetVMStats(ctx context.Context, idOrName string) (model.VMRecord, model.VMStats, error) {
d.mu.Lock()
defer d.mu.Unlock()
vm, err := d.FindVM(ctx, idOrName)
if err != nil {
return model.VMRecord{}, model.VMStats{}, err
}
stats, err := d.collectStats(ctx, vm)
if err == nil {
vm.Stats = stats
vm.UpdatedAt = model.Now()
_ = d.store.UpsertVM(ctx, vm)
}
return vm, vm.Stats, nil
}
func (d *Daemon) pollStats(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
vms, err := d.store.ListVMs(ctx)
if err != nil {
return err
}
for _, vm := range vms {
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
continue
}
stats, err := d.collectStats(ctx, vm)
if err != nil {
continue
}
vm.Stats = stats
vm.UpdatedAt = model.Now()
_ = d.store.UpsertVM(ctx, vm)
}
return nil
}
func (d *Daemon) stopStaleVMs(ctx context.Context) error {
if d.config.AutoStopStaleAfter <= 0 {
return nil
}
d.mu.Lock()
defer d.mu.Unlock()
vms, err := d.store.ListVMs(ctx)
if err != nil {
return err
}
now := model.Now()
for _, vm := range vms {
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
continue
}
if now.Sub(vm.LastTouchedAt) < d.config.AutoStopStaleAfter {
continue
}
_ = d.sendCtrlAltDel(ctx, vm)
_ = d.waitForExit(ctx, vm.Runtime.PID, vm.Runtime.APISockPath, 10*time.Second)
_ = d.cleanupRuntime(ctx, vm, true)
vm.State = model.VMStateStopped
vm.Runtime.State = model.VMStateStopped
clearRuntimeHandles(&vm)
vm.UpdatedAt = model.Now()
_ = d.store.UpsertVM(ctx, vm)
}
return nil
}
func (d *Daemon) collectStats(ctx context.Context, vm model.VMRecord) (model.VMStats, error) {
stats := model.VMStats{
CollectedAt: model.Now(),
SystemOverlayBytes: system.AllocatedBytes(vm.Runtime.SystemOverlay),
WorkDiskBytes: system.AllocatedBytes(vm.Runtime.WorkDiskPath),
MetricsRaw: system.ParseMetricsFile(vm.Runtime.MetricsPath),
}
if vm.Runtime.PID > 0 && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
ps, err := system.ReadProcessStats(ctx, vm.Runtime.PID)
if err == nil {
stats.CPUPercent = ps.CPUPercent
stats.RSSBytes = ps.RSSBytes
stats.VSZBytes = ps.VSZBytes
}
}
return stats, nil
}
func (d *Daemon) ensureSystemOverlay(ctx context.Context, vm *model.VMRecord) error {
if exists(vm.Runtime.SystemOverlay) {
return nil
}
_, err := d.runner.Run(ctx, "truncate", "-s", strconv.FormatInt(vm.Spec.SystemOverlaySizeByte, 10), vm.Runtime.SystemOverlay)
return err
}
func (d *Daemon) patchRootOverlay(ctx context.Context, vm model.VMRecord, image model.Image) error {
resolv := []byte(fmt.Sprintf("nameserver %s\n", d.config.DefaultDNS))
hostname := []byte(vm.Name + "\n")
hosts := []byte(fmt.Sprintf("127.0.0.1 localhost\n127.0.1.1 %s\n", vm.Name))
fstab, err := system.ReadDebugFSText(ctx, d.runner, vm.Runtime.DMDev, "/etc/fstab")
if err != nil {
fstab = ""
}
newFSTab := system.UpdateFSTab(fstab)
for guestPath, data := range map[string][]byte{
"/etc/resolv.conf": resolv,
"/etc/hostname": hostname,
"/etc/hosts": hosts,
"/etc/fstab": []byte(newFSTab),
} {
if err := system.WriteExt4File(ctx, d.runner, vm.Runtime.DMDev, guestPath, data); err != nil {
return err
}
}
return nil
}
func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord) error {
if exists(vm.Runtime.WorkDiskPath) {
return nil
}
if _, err := d.runner.Run(ctx, "truncate", "-s", strconv.FormatInt(vm.Spec.WorkDiskSizeBytes, 10), vm.Runtime.WorkDiskPath); err != nil {
return err
}
if _, err := d.runner.Run(ctx, "mkfs.ext4", "-F", vm.Runtime.WorkDiskPath); err != nil {
return err
}
rootMount, cleanupRoot, err := system.MountTempDir(ctx, d.runner, vm.Runtime.DMDev, true)
if err != nil {
return err
}
defer cleanupRoot()
workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false)
if err != nil {
return err
}
defer cleanupWork()
if err := system.CopyDirContents(ctx, d.runner, filepath.Join(rootMount, "root"), workMount, true); err != nil {
return err
}
return nil
}
func (d *Daemon) createDMSnapshot(ctx context.Context, rootfsPath, cowPath, dmName string) (baseLoop, cowLoop, dmDev string, err error) {
baseBytes, err := d.runner.RunSudo(ctx, "losetup", "-f", "--show", "--read-only", rootfsPath)
if err != nil {
return "", "", "", err
}
baseLoop = strings.TrimSpace(string(baseBytes))
cowBytes, err := d.runner.RunSudo(ctx, "losetup", "-f", "--show", cowPath)
if err != nil {
return "", "", "", err
}
cowLoop = strings.TrimSpace(string(cowBytes))
sectorsBytes, err := d.runner.RunSudo(ctx, "blockdev", "--getsz", baseLoop)
if err != nil {
return "", "", "", err
}
sectors := strings.TrimSpace(string(sectorsBytes))
if _, err := d.runner.RunSudo(ctx, "dmsetup", "create", dmName, "--table", fmt.Sprintf("0 %s snapshot %s %s P 8", sectors, baseLoop, cowLoop)); err != nil {
return "", "", "", err
}
return baseLoop, cowLoop, "/dev/mapper/" + dmName, nil
}
func (d *Daemon) ensureBridge(ctx context.Context) error {
if _, err := d.runner.Run(ctx, "ip", "link", "show", d.config.BridgeName); err == nil {
_, err = d.runner.RunSudo(ctx, "ip", "link", "set", d.config.BridgeName, "up")
return err
}
if _, err := d.runner.RunSudo(ctx, "ip", "link", "add", "name", d.config.BridgeName, "type", "bridge"); err != nil {
return err
}
if _, err := d.runner.RunSudo(ctx, "ip", "addr", "add", fmt.Sprintf("%s/%s", d.config.BridgeIP, d.config.CIDR), "dev", d.config.BridgeName); err != nil {
return err
}
_, err := d.runner.RunSudo(ctx, "ip", "link", "set", d.config.BridgeName, "up")
return err
}
func (d *Daemon) ensureSocketDir() error {
return os.MkdirAll(d.layout.RuntimeDir, 0o755)
}
func (d *Daemon) createTap(ctx context.Context, tap string) error {
if _, err := d.runner.Run(ctx, "ip", "link", "show", tap); err == nil {
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", tap)
}
if _, err := d.runner.RunSudo(ctx, "ip", "tuntap", "add", "dev", tap, "mode", "tap", "user", strconv.Itoa(os.Getuid()), "group", strconv.Itoa(os.Getgid())); err != nil {
return err
}
if _, err := d.runner.RunSudo(ctx, "ip", "link", "set", tap, "master", d.config.BridgeName); err != nil {
return err
}
if _, err := d.runner.RunSudo(ctx, "ip", "link", "set", tap, "up"); err != nil {
return err
}
_, err := d.runner.RunSudo(ctx, "ip", "link", "set", d.config.BridgeName, "up")
return err
}
func (d *Daemon) firecrackerBinary() (string, error) {
if d.config.RepoRoot == "" {
return "", errors.New("repo root not detected")
}
path := filepath.Join(d.config.RepoRoot, "firecracker")
if !exists(path) {
return "", fmt.Errorf("firecracker binary not found at %s", path)
}
return path, nil
}
func (d *Daemon) startFirecrackerProcess(ctx context.Context, fcBin, apiSock, logPath string) (int, error) {
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return 0, err
}
cmd := exec.CommandContext(ctx, "sudo", "-n", fcBin, "--api-sock", apiSock)
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
_ = logFile.Close()
return 0, err
}
go func() {
_ = cmd.Wait()
_ = logFile.Close()
}()
return cmd.Process.Pid, nil
}
func (d *Daemon) waitForSocket(ctx context.Context, apiSock string) error {
deadline := time.Now().Add(15 * time.Second)
var lastErr error
for {
if _, err := os.Stat(apiSock); err == nil {
if err := d.ensureSocketAccess(ctx, apiSock); err != nil {
lastErr = err
} else {
conn, dialErr := net.DialTimeout("unix", apiSock, 200*time.Millisecond)
if dialErr == nil {
_ = conn.Close()
return nil
}
lastErr = dialErr
}
} else if !os.IsNotExist(err) {
lastErr = err
}
if time.Now().After(deadline) {
if lastErr != nil {
return fmt.Errorf("firecracker api socket not ready: %s: %w", apiSock, lastErr)
}
return fmt.Errorf("firecracker api socket not ready: %s", apiSock)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(20 * time.Millisecond):
}
}
}
func (d *Daemon) ensureSocketAccess(ctx context.Context, apiSock string) error {
if _, err := d.runner.RunSudo(ctx, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), apiSock); err != nil {
return err
}
_, err := d.runner.RunSudo(ctx, "chmod", "600", apiSock)
return err
}
func (d *Daemon) findFirecrackerPID(ctx context.Context, apiSock string) (int, error) {
out, err := d.runner.Run(ctx, "pgrep", "-n", "-f", apiSock)
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(out)))
}
func (d *Daemon) sendCtrlAltDel(ctx context.Context, vm model.VMRecord) error {
if err := d.ensureSocketAccess(ctx, vm.Runtime.APISockPath); err != nil {
return err
}
client := firecracker.New(vm.Runtime.APISockPath)
return client.Put(ctx, "/actions", map[string]any{"action_type": "SendCtrlAltDel"})
}
func (d *Daemon) waitForExit(ctx context.Context, pid int, apiSock string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if !system.ProcessRunning(pid, apiSock) {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out waiting for VM to exit")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
func (d *Daemon) cleanupRuntime(ctx context.Context, vm model.VMRecord, preserveDisks bool) error {
if vm.Runtime.PID > 0 && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
_ = d.killVMProcess(ctx, vm.Runtime.PID)
}
if vm.Runtime.TapDevice != "" {
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.Runtime.TapDevice)
}
if vm.Runtime.APISockPath != "" {
_ = os.Remove(vm.Runtime.APISockPath)
}
if vm.Runtime.DMName != "" {
_, _ = d.runner.RunSudo(ctx, "dmsetup", "remove", vm.Runtime.DMName)
} else if vm.Runtime.DMDev != "" {
_, _ = d.runner.RunSudo(ctx, "dmsetup", "remove", vm.Runtime.DMDev)
}
if vm.Runtime.COWLoop != "" {
_, _ = d.runner.RunSudo(ctx, "losetup", "-d", vm.Runtime.COWLoop)
}
if vm.Runtime.BaseLoop != "" {
_, _ = d.runner.RunSudo(ctx, "losetup", "-d", vm.Runtime.BaseLoop)
}
if vm.Spec.NATEnabled {
_ = d.ensureNAT(ctx, vm, false)
}
_ = d.removeDNS(ctx, vm.Runtime.DNSName)
if !preserveDisks && vm.Runtime.VMDir != "" {
return os.RemoveAll(vm.Runtime.VMDir)
}
return nil
}
func clearRuntimeHandles(vm *model.VMRecord) {
vm.Runtime.PID = 0
vm.Runtime.APISockPath = ""
vm.Runtime.TapDevice = ""
vm.Runtime.BaseLoop = ""
vm.Runtime.COWLoop = ""
vm.Runtime.DMName = ""
vm.Runtime.DMDev = ""
vm.Runtime.FirecrackerState = nil
}
func (d *Daemon) setDNS(ctx context.Context, vmName, guestIP string) error {
_, err := d.runner.Run(ctx, "mapdns", "set", "--data-file", "/home/thales/.local/share/mapdns/records.json", vmName+".vm", guestIP)
return err
}
func (d *Daemon) removeDNS(ctx context.Context, dnsName string) error {
if dnsName == "" {
return nil
}
_, err := d.runner.Run(ctx, "mapdns", "rm", "--data-file", "/home/thales/.local/share/mapdns/records.json", dnsName)
if err != nil && strings.Contains(err.Error(), "not found") {
return nil
}
return err
}
func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) error {
if d.config.RepoRoot == "" {
return errors.New("repo root not detected")
}
script := filepath.Join(d.config.RepoRoot, "nat.sh")
action := "down"
if enable {
action = "up"
}
cmd := exec.CommandContext(ctx, "bash", script, action, vm.ID)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Dir = d.config.RepoRoot
return cmd.Run()
}
func (d *Daemon) killVMProcess(ctx context.Context, pid int) error {
_, err := d.runner.RunSudo(ctx, "kill", "-KILL", strconv.Itoa(pid))
return err
}
func (d *Daemon) requireStartPrereqs(ctx context.Context) error {
return system.RequireCommands(
ctx,
"sudo",
"ip",
"curl",
"jq",
"dmsetup",
"losetup",
"blockdev",
"e2cp",
"e2rm",
"debugfs",
"mkfs.ext4",
"truncate",
"pgrep",
"mount",
"umount",
"cp",
"ps",
"mapdns",
)
}
func (d *Daemon) generateName(ctx context.Context) (string, error) {
if d.config.RepoRoot != "" {
namegen := filepath.Join(d.config.RepoRoot, "namegen")
if exists(namegen) {
out, err := d.runner.Run(ctx, namegen)
if err == nil {
name := strings.TrimSpace(string(out))
if name != "" {
return name, nil
}
}
}
}
return "vm-" + strconv.FormatInt(time.Now().Unix(), 10), nil
}
func bridgePrefix(bridgeIP string) string {
parts := strings.Split(bridgeIP, ".")
if len(parts) < 3 {
return bridgeIP
}
return strings.Join(parts[:3], ".")
}
func defaultInt(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}

View file

@ -0,0 +1,67 @@
package firecracker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"banger/internal/rpc"
)
type Client struct {
http *http.Client
}
func New(apiSock string) *Client {
return &Client{http: rpc.NewUnixHTTPClient(apiSock)}
}
func (c *Client) Put(ctx context.Context, path string, body any) error {
var payload io.Reader = http.NoBody
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
payload = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://localhost"+path, payload)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("firecracker %s failed: %s", path, bytes.TrimSpace(data))
}
return nil
}
func (c *Client) GetConfig(ctx context.Context) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost/vm/config", nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("firecracker config failed: %s", bytes.TrimSpace(data))
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}

213
internal/model/types.go Normal file
View file

@ -0,0 +1,213 @@
package model
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
const (
DefaultBridgeName = "br-fc"
DefaultBridgeIP = "172.16.0.1"
DefaultCIDR = "24"
DefaultDNS = "1.1.1.1"
DefaultSystemOverlaySize = 8 * 1024 * 1024 * 1024
DefaultWorkDiskSize = 8 * 1024 * 1024 * 1024
DefaultMemoryMiB = 1024
DefaultVCPUCount = 2
DefaultStatsPollInterval = 10 * time.Second
DefaultStaleSweepInterval = 1 * time.Minute
DefaultMetricsPollInterval = 15 * time.Second
MaxDiskBytes int64 = 128 * 1024 * 1024 * 1024
)
type VMState string
const (
VMStateCreated VMState = "created"
VMStateRunning VMState = "running"
VMStateStopped VMState = "stopped"
VMStateError VMState = "error"
)
type DaemonConfig struct {
RepoRoot string
AutoStopStaleAfter time.Duration
StatsPollInterval time.Duration
MetricsPollInterval time.Duration
BridgeName string
BridgeIP string
CIDR string
DefaultDNS string
DefaultImageName string
DefaultBaseRootfs string
DefaultKernel string
DefaultInitrd string
DefaultModulesDir string
DefaultPackagesFile string
}
type Image struct {
ID string `json:"id"`
Name string `json:"name"`
Managed bool `json:"managed"`
ArtifactDir string `json:"artifact_dir,omitempty"`
RootfsPath string `json:"rootfs_path"`
KernelPath string `json:"kernel_path"`
InitrdPath string `json:"initrd_path,omitempty"`
ModulesDir string `json:"modules_dir,omitempty"`
PackagesPath string `json:"packages_path,omitempty"`
BuildSize string `json:"build_size,omitempty"`
Docker bool `json:"docker"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type VMSpec struct {
VCPUCount int `json:"vcpu_count"`
MemoryMiB int `json:"memory_mib"`
SystemOverlaySizeByte int64 `json:"system_overlay_size_bytes"`
WorkDiskSizeBytes int64 `json:"work_disk_size_bytes"`
NATEnabled bool `json:"nat_enabled"`
}
type VMRuntime struct {
State VMState `json:"state"`
PID int `json:"pid,omitempty"`
GuestIP string `json:"guest_ip"`
TapDevice string `json:"tap_device,omitempty"`
APISockPath string `json:"api_sock_path,omitempty"`
LogPath string `json:"log_path,omitempty"`
MetricsPath string `json:"metrics_path,omitempty"`
DNSName string `json:"dns_name,omitempty"`
VMDir string `json:"vm_dir"`
SystemOverlay string `json:"system_overlay_path"`
WorkDiskPath string `json:"work_disk_path"`
BaseLoop string `json:"base_loop,omitempty"`
COWLoop string `json:"cow_loop,omitempty"`
DMName string `json:"dm_name,omitempty"`
DMDev string `json:"dm_dev,omitempty"`
LastError string `json:"last_error,omitempty"`
FirecrackerState map[string]any `json:"firecracker_state,omitempty"`
}
type VMStats struct {
CollectedAt time.Time `json:"collected_at,omitempty"`
CPUPercent float64 `json:"cpu_percent,omitempty"`
RSSBytes int64 `json:"rss_bytes,omitempty"`
VSZBytes int64 `json:"vsz_bytes,omitempty"`
SystemOverlayBytes int64 `json:"system_overlay_bytes,omitempty"`
WorkDiskBytes int64 `json:"work_disk_bytes,omitempty"`
MetricsRaw map[string]any `json:"metrics_raw,omitempty"`
}
type VMRecord struct {
ID string `json:"id"`
Name string `json:"name"`
ImageID string `json:"image_id"`
State VMState `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastTouchedAt time.Time `json:"last_touched_at"`
Spec VMSpec `json:"spec"`
Runtime VMRuntime `json:"runtime"`
Stats VMStats `json:"stats"`
}
type VMCreateRequest struct {
Name string
ImageName string
VCPUCount int
MemoryMiB int
SystemOverlaySizeByte int64
WorkDiskSizeBytes int64
NATEnabled bool
NoStart bool
}
type VMSetRequest struct {
IDOrName string
VCPUCount *int
MemoryMiB *int
WorkDiskSizeBytes *int64
NATEnabled *bool
}
type ImageBuildRequest struct {
Name string
BaseRootfs string
Size string
KernelPath string
InitrdPath string
ModulesDir string
Docker bool
}
func Now() time.Time {
return time.Now().UTC().Truncate(time.Second)
}
func NewID() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func ParseSize(raw string) (int64, error) {
if raw == "" {
return 0, errors.New("size is required")
}
raw = strings.TrimSpace(strings.ToUpper(raw))
if raw == "" {
return 0, errors.New("size is required")
}
unit := raw[len(raw)-1]
multiplier := int64(1024 * 1024)
number := raw
switch unit {
case 'K':
multiplier = 1024
number = raw[:len(raw)-1]
case 'M':
multiplier = 1024 * 1024
number = raw[:len(raw)-1]
case 'G':
multiplier = 1024 * 1024 * 1024
number = raw[:len(raw)-1]
default:
if unit < '0' || unit > '9' {
return 0, fmt.Errorf("unsupported size suffix: %q", string(unit))
}
}
value, err := strconv.ParseInt(number, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse size %q: %w", raw, err)
}
result := value * multiplier
if result <= 0 {
return 0, fmt.Errorf("size must be positive: %q", raw)
}
if result > MaxDiskBytes {
return 0, fmt.Errorf("size exceeds max of %d bytes", MaxDiskBytes)
}
return result, nil
}
func FormatSizeBytes(bytes int64) string {
switch {
case bytes%(1024*1024*1024) == 0:
return fmt.Sprintf("%dG", bytes/(1024*1024*1024))
case bytes%(1024*1024) == 0:
return fmt.Sprintf("%dM", bytes/(1024*1024))
case bytes%1024 == 0:
return fmt.Sprintf("%dK", bytes/1024)
default:
return strconv.FormatInt(bytes, 10)
}
}

154
internal/paths/paths.go Normal file
View file

@ -0,0 +1,154 @@
package paths
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
type Layout struct {
ConfigHome string
StateHome string
CacheHome string
RuntimeHome string
ConfigDir string
StateDir string
CacheDir string
RuntimeDir string
SocketPath string
DBPath string
DaemonLog string
VMsDir string
ImagesDir string
}
func Resolve() (Layout, error) {
home, err := os.UserHomeDir()
if err != nil {
return Layout{}, err
}
configHome := getenvDefault("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
stateHome := getenvDefault("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
cacheHome := getenvDefault("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
runtimeHome := os.Getenv("XDG_RUNTIME_DIR")
if runtimeHome == "" {
runtimeHome = filepath.Join(os.TempDir(), fmt.Sprintf("banger-runtime-%d", os.Getuid()))
}
layout := Layout{
ConfigHome: configHome,
StateHome: stateHome,
CacheHome: cacheHome,
RuntimeHome: runtimeHome,
ConfigDir: filepath.Join(configHome, "banger"),
StateDir: filepath.Join(stateHome, "banger"),
CacheDir: filepath.Join(cacheHome, "banger"),
RuntimeDir: filepath.Join(runtimeHome, "banger"),
}
layout.SocketPath = filepath.Join(layout.RuntimeDir, "bangerd.sock")
layout.DBPath = filepath.Join(layout.StateDir, "state.db")
layout.DaemonLog = filepath.Join(layout.StateDir, "bangerd.log")
layout.VMsDir = filepath.Join(layout.StateDir, "vms")
layout.ImagesDir = filepath.Join(layout.StateDir, "images")
return layout, nil
}
func Ensure(layout Layout) error {
for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return nil
}
func DetectRepoRoot() string {
if env := os.Getenv("BANGER_REPO_ROOT"); env != "" {
return env
}
candidates := []string{}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, wd)
}
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(exe))
}
if look, err := exec.LookPath("firecracker"); err == nil {
candidates = append(candidates, filepath.Dir(look))
}
for _, candidate := range candidates {
if root := walkForRepoRoot(candidate); root != "" {
return root
}
}
return ""
}
func walkForRepoRoot(start string) string {
current := start
for {
if hasRepoArtifacts(current) {
return current
}
parent := filepath.Dir(current)
if parent == current {
return ""
}
current = parent
}
}
func hasRepoArtifacts(dir string) bool {
required := []string{"firecracker", "README.md"}
for _, name := range required {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
return false
}
}
return true
}
func BangerdPath() (string, error) {
if env := os.Getenv("BANGER_DAEMON_BIN"); env != "" {
return env, nil
}
exe, err := os.Executable()
if err != nil {
return "", err
}
dir := filepath.Dir(exe)
for _, candidate := range []string{
filepath.Join(dir, "bangerd"),
filepath.Join(dir, "bangerd.exe"),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
if root := DetectRepoRoot(); root != "" {
for _, candidate := range []string{
filepath.Join(root, "bangerd"),
filepath.Join(root, "bangerd.exe"),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
}
return "", errors.New("bangerd binary not found next to banger; build ./cmd/bangerd")
}
func getenvDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func RuntimeFallbackLabel() string {
return strconv.Itoa(os.Getuid())
}

128
internal/rpc/rpc.go Normal file
View file

@ -0,0 +1,128 @@
package rpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
)
const Version = 1
type Request struct {
Version int `json:"version"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type Response struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *ErrorResponse `json:"error,omitempty"`
}
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
func NewResult(v any) (Response, error) {
data, err := json.Marshal(v)
if err != nil {
return Response{}, err
}
return Response{OK: true, Result: data}, nil
}
func NewError(code, message string) Response {
return Response{OK: false, Error: &ErrorResponse{Code: code, Message: message}}
}
func DecodeParams[T any](req Request) (T, error) {
var zero T
if len(req.Params) == 0 {
return zero, nil
}
var out T
if err := json.Unmarshal(req.Params, &out); err != nil {
return zero, err
}
return out, nil
}
func Call[T any](ctx context.Context, socketPath, method string, params any) (T, error) {
var zero T
conn, err := net.DialTimeout("unix", socketPath, 2*time.Second)
if err != nil {
return zero, err
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
request := Request{Version: Version, Method: method}
if params != nil {
raw, err := json.Marshal(params)
if err != nil {
return zero, err
}
request.Params = raw
}
if err := json.NewEncoder(conn).Encode(request); err != nil {
return zero, err
}
var response Response
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&response); err != nil {
return zero, err
}
if !response.OK {
if response.Error == nil {
return zero, errors.New("rpc error")
}
return zero, fmt.Errorf("%s: %s", response.Error.Code, response.Error.Message)
}
if len(response.Result) == 0 {
return zero, nil
}
var result T
if err := json.Unmarshal(response.Result, &result); err != nil {
return zero, err
}
return result, nil
}
func WaitForSocket(path string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if _, err := os.Stat(path); err == nil {
conn, err := net.DialTimeout("unix", path, 500*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("socket %s not ready", path)
}
time.Sleep(100 * time.Millisecond)
}
}
func NewUnixHTTPClient(socketPath string) *http.Client {
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
},
},
}
}

386
internal/store/store.go Normal file
View file

@ -0,0 +1,386 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
"banger/internal/model"
)
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
store := &Store{db: db}
if err := store.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
stmts := []string{
`PRAGMA journal_mode=WAL;`,
`CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
managed INTEGER NOT NULL DEFAULT 0,
artifact_dir TEXT,
rootfs_path TEXT NOT NULL,
kernel_path TEXT NOT NULL,
initrd_path TEXT,
modules_dir TEXT,
packages_path TEXT,
build_size TEXT,
docker INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS vms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
image_id TEXT NOT NULL,
guest_ip TEXT NOT NULL UNIQUE,
state TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_touched_at TEXT NOT NULL,
spec_json TEXT NOT NULL,
runtime_json TEXT NOT NULL,
stats_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) UpsertImage(ctx context.Context, image model.Image) error {
const query = `
INSERT INTO images (
id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path,
modules_dir, packages_path, build_size, docker, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name,
managed=excluded.managed,
artifact_dir=excluded.artifact_dir,
rootfs_path=excluded.rootfs_path,
kernel_path=excluded.kernel_path,
initrd_path=excluded.initrd_path,
modules_dir=excluded.modules_dir,
packages_path=excluded.packages_path,
build_size=excluded.build_size,
docker=excluded.docker,
updated_at=excluded.updated_at`
_, err := s.db.ExecContext(ctx, query,
image.ID,
image.Name,
boolToInt(image.Managed),
image.ArtifactDir,
image.RootfsPath,
image.KernelPath,
image.InitrdPath,
image.ModulesDir,
image.PackagesPath,
image.BuildSize,
boolToInt(image.Docker),
image.CreatedAt.Format(time.RFC3339),
image.UpdatedAt.Format(time.RFC3339),
)
return err
}
func (s *Store) GetImageByName(ctx context.Context, name string) (model.Image, error) {
return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE name = ?", name)
}
func (s *Store) GetImageByID(ctx context.Context, id string) (model.Image, error) {
return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE id = ?", id)
}
func (s *Store) ListImages(ctx context.Context) ([]model.Image, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images ORDER BY created_at ASC")
if err != nil {
return nil, err
}
defer rows.Close()
var images []model.Image
for rows.Next() {
image, err := scanImage(rows)
if err != nil {
return nil, err
}
images = append(images, image)
}
return images, rows.Err()
}
func (s *Store) DeleteImage(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM images WHERE id = ?", id)
return err
}
func (s *Store) UpsertVM(ctx context.Context, vm model.VMRecord) error {
specJSON, err := json.Marshal(vm.Spec)
if err != nil {
return err
}
runtimeJSON, err := json.Marshal(vm.Runtime)
if err != nil {
return err
}
statsJSON, err := json.Marshal(vm.Stats)
if err != nil {
return err
}
const query = `
INSERT INTO vms (
id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name,
image_id=excluded.image_id,
guest_ip=excluded.guest_ip,
state=excluded.state,
updated_at=excluded.updated_at,
last_touched_at=excluded.last_touched_at,
spec_json=excluded.spec_json,
runtime_json=excluded.runtime_json,
stats_json=excluded.stats_json`
_, err = s.db.ExecContext(ctx, query,
vm.ID,
vm.Name,
vm.ImageID,
vm.Runtime.GuestIP,
string(vm.State),
vm.CreatedAt.Format(time.RFC3339),
vm.UpdatedAt.Format(time.RFC3339),
vm.LastTouchedAt.Format(time.RFC3339),
string(specJSON),
string(runtimeJSON),
string(statsJSON),
)
return err
}
func (s *Store) GetVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
const query = `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
FROM vms
WHERE id = ? OR name = ?
`
row := s.db.QueryRowContext(ctx, query, idOrName, idOrName)
return scanVMRow(row)
}
func (s *Store) GetVMByID(ctx context.Context, id string) (model.VMRecord, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
FROM vms WHERE id = ?`, id)
return scanVMRow(row)
}
func (s *Store) ListVMs(ctx context.Context) ([]model.VMRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
FROM vms ORDER BY created_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var vms []model.VMRecord
for rows.Next() {
vm, err := scanVMRows(rows)
if err != nil {
return nil, err
}
vms = append(vms, vm)
}
return vms, rows.Err()
}
func (s *Store) DeleteVM(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM vms WHERE id = ?", id)
return err
}
func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.VMRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
FROM vms WHERE image_id = ?`, imageID)
if err != nil {
return nil, err
}
defer rows.Close()
var vms []model.VMRecord
for rows.Next() {
vm, err := scanVMRows(rows)
if err != nil {
return nil, err
}
vms = append(vms, vm)
}
return vms, rows.Err()
}
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
used := map[string]struct{}{}
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
if err != nil {
return "", err
}
defer rows.Close()
for rows.Next() {
var ip string
if err := rows.Scan(&ip); err != nil {
return "", err
}
used[ip] = struct{}{}
}
if err := rows.Err(); err != nil {
return "", err
}
for i := 2; i < 255; i++ {
candidate := fmt.Sprintf("%s.%d", bridgeIPPrefix, i)
if _, exists := used[candidate]; !exists {
return candidate, nil
}
}
return "", errors.New("no guest IPs available")
}
func (s *Store) getImage(ctx context.Context, query string, arg string) (model.Image, error) {
row := s.db.QueryRowContext(ctx, query, arg)
return scanImageRow(row)
}
func scanImage(rows scanner) (model.Image, error) {
return scanImageRow(rows)
}
type scanner interface {
Scan(dest ...any) error
}
func scanImageRow(row scanner) (model.Image, error) {
var image model.Image
var managed, docker int
var createdAt, updatedAt string
err := row.Scan(
&image.ID,
&image.Name,
&managed,
&image.ArtifactDir,
&image.RootfsPath,
&image.KernelPath,
&image.InitrdPath,
&image.ModulesDir,
&image.PackagesPath,
&image.BuildSize,
&docker,
&createdAt,
&updatedAt,
)
if err != nil {
return image, err
}
image.Managed = managed == 1
image.Docker = docker == 1
image.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
if err != nil {
return image, err
}
image.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt)
if err != nil {
return image, err
}
return image, nil
}
func scanVMRow(row scanner) (model.VMRecord, error) {
return scanVMInto(row)
}
func scanVMRows(rows scanner) (model.VMRecord, error) {
return scanVMInto(rows)
}
func scanVMInto(row scanner) (model.VMRecord, error) {
var vm model.VMRecord
var state, createdAt, updatedAt, touchedAt, specJSON, runtimeJSON, statsJSON string
err := row.Scan(
&vm.ID,
&vm.Name,
&vm.ImageID,
&vm.Runtime.GuestIP,
&state,
&createdAt,
&updatedAt,
&touchedAt,
&specJSON,
&runtimeJSON,
&statsJSON,
)
if err != nil {
return vm, err
}
vm.State = model.VMState(state)
if err := json.Unmarshal([]byte(specJSON), &vm.Spec); err != nil {
return vm, err
}
if err := json.Unmarshal([]byte(runtimeJSON), &vm.Runtime); err != nil {
return vm, err
}
if statsJSON != "" {
if err := json.Unmarshal([]byte(statsJSON), &vm.Stats); err != nil {
return vm, err
}
}
var parseErr error
vm.CreatedAt, parseErr = time.Parse(time.RFC3339, createdAt)
if parseErr != nil {
return vm, parseErr
}
vm.UpdatedAt, parseErr = time.Parse(time.RFC3339, updatedAt)
if parseErr != nil {
return vm, parseErr
}
vm.LastTouchedAt, parseErr = time.Parse(time.RFC3339, touchedAt)
if parseErr != nil {
return vm, parseErr
}
return vm, nil
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}

321
internal/system/system.go Normal file
View file

@ -0,0 +1,321 @@
package system
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"banger/internal/model"
)
type Runner struct{}
func NewRunner() Runner {
return Runner{}
}
func (Runner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
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
}
func (r Runner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
all := append([]string{"-n"}, args...)
return r.Run(ctx, "sudo", all...)
}
func EnsureSudo(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "sudo", "-v")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func RequireCommands(ctx context.Context, commands ...string) error {
for _, command := range commands {
if _, err := exec.LookPath(command); err != nil {
return fmt.Errorf("required command %q not found", command)
}
}
return nil
}
func WriteJSON(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func AllocatedBytes(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return info.Size()
}
return stat.Blocks * 512
}
func ProcessRunning(pid int, apiSock string) bool {
if pid <= 0 || apiSock == "" {
return false
}
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)
}
type ProcessStats struct {
CPUPercent float64
RSSBytes int64
VSZBytes int64
}
func ReadProcessStats(ctx context.Context, pid int) (ProcessStats, error) {
if pid <= 0 {
return ProcessStats{}, errors.New("pid is required")
}
runner := NewRunner()
out, err := runner.Run(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "%cpu=,rss=,vsz=")
if err != nil {
return ProcessStats{}, err
}
fields := strings.Fields(string(out))
if len(fields) < 3 {
return ProcessStats{}, fmt.Errorf("unexpected ps output: %q", string(out))
}
cpu, _ := strconv.ParseFloat(fields[0], 64)
rssKB, _ := strconv.ParseInt(fields[1], 10, 64)
vszKB, _ := strconv.ParseInt(fields[2], 10, 64)
return ProcessStats{
CPUPercent: cpu,
RSSBytes: rssKB * 1024,
VSZBytes: vszKB * 1024,
}, nil
}
func TailCommand(path string, follow bool) *exec.Cmd {
if follow {
return exec.Command("tail", "-f", path)
}
return exec.Command("cat", path)
}
func ParseMetricsFile(path string) map[string]any {
data, err := os.ReadFile(path)
if err != nil || len(bytes.TrimSpace(data)) == 0 {
return nil
}
raw := bytes.TrimSpace(data)
var result map[string]any
if err := json.Unmarshal(raw, &result); err == nil {
return result
}
lastLine := lastJSONLine(raw)
if lastLine == nil {
return nil
}
if err := json.Unmarshal(lastLine, &result); err != nil {
return nil
}
return result
}
func lastJSONLine(data []byte) []byte {
scanner := bufio.NewScanner(bytes.NewReader(data))
var last []byte
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
last = append([]byte(nil), line...)
}
return last
}
func CopyDirContents(ctx context.Context, runner Runner, sourceDir, targetDir string, useSudo bool) error {
args := []string{"-a", filepath.Join(sourceDir, "."), targetDir + "/"}
var err error
if useSudo {
_, err = runner.RunSudo(ctx, append([]string{"cp"}, args...)...)
} else {
_, err = runner.Run(ctx, "cp", args...)
}
return err
}
func ResizeExt4Image(ctx context.Context, runner Runner, path string, bytes int64) error {
if _, err := runner.Run(ctx, "truncate", "-s", strconv.FormatInt(bytes, 10), path); err != nil {
return err
}
if _, err := runner.Run(ctx, "e2fsck", "-p", "-f", path); err != nil {
return err
}
_, err := runner.Run(ctx, "resize2fs", path)
return err
}
func ReadDebugFSText(ctx context.Context, runner Runner, imagePath, guestPath string) (string, error) {
out, err := runner.Run(ctx, "debugfs", "-R", "cat "+guestPath, imagePath)
if err != nil {
return "", err
}
return string(out), nil
}
func WriteExt4File(ctx context.Context, runner Runner, imagePath, guestPath string, data []byte) error {
tmp, err := os.CreateTemp("", "banger-ext4-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
_, _ = runner.RunSudo(ctx, "e2rm", imagePath+":"+guestPath)
_, err = runner.RunSudo(ctx, "e2cp", tmp.Name(), imagePath+":"+guestPath)
return err
}
func MountTempDir(ctx context.Context, runner Runner, source string, readOnly bool) (string, func() error, error) {
mountDir, err := os.MkdirTemp("", "banger-mnt-*")
if err != nil {
return "", nil, err
}
args := []string{"mount"}
var opts []string
if readOnly {
opts = append(opts, "ro")
}
if useLoopMount(source) {
opts = append(opts, "loop")
}
if len(opts) > 0 {
args = append(args, "-o", strings.Join(opts, ","))
}
args = append(args, source, mountDir)
if _, err := runner.RunSudo(ctx, args...); err != nil {
_ = os.RemoveAll(mountDir)
return "", nil, err
}
cleanup := func() error {
_, err := runner.RunSudo(context.Background(), "umount", mountDir)
_ = os.RemoveAll(mountDir)
return err
}
return mountDir, cleanup, nil
}
func useLoopMount(source string) bool {
info, err := os.Stat(source)
if err != nil {
return false
}
return info.Mode().IsRegular()
}
func UpdateFSTab(existing string) string {
lines := strings.Split(existing, "\n")
var out []string
hasRoot := false
hasRun := false
hasTmp := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
fields := strings.Fields(trimmed)
if len(fields) >= 2 {
if fields[0] == "/dev/vdb" && fields[1] == "/home" {
continue
}
if fields[0] == "/dev/vdc" && fields[1] == "/var" {
continue
}
if fields[0] == "/dev/vdb" && fields[1] == "/root" {
hasRoot = true
}
if fields[0] == "tmpfs" && fields[1] == "/run" {
hasRun = true
}
if fields[0] == "tmpfs" && fields[1] == "/tmp" {
hasTmp = true
}
}
out = append(out, line)
}
if !hasRoot {
out = append(out, "/dev/vdb /root ext4 defaults 0 2")
}
if !hasRun {
out = append(out, "tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0")
}
if !hasTmp {
out = append(out, "tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0")
}
return strings.Join(out, "\n") + "\n"
}
func BuildBootArgs(vmName, guestIP, bridgeIP, dns string) string {
return fmt.Sprintf(
"console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=%s::%s:255.255.255.0::eth0:off:%s hostname=%s systemd.mask=home.mount systemd.mask=var.mount",
guestIP,
bridgeIP,
dns,
vmName,
)
}
func ShortID(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}
func TouchNow(vm *model.VMRecord) {
now := model.Now()
vm.UpdatedAt = now
vm.LastTouchedAt = now
}
func CopyStream(dst io.Writer, cmd *exec.Cmd) error {
cmd.Stdout = dst
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}