diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..de93ce2 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 5b5a7b3..44ca143 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/banger/main.go b/cmd/banger/main.go new file mode 100644 index 0000000..f7a616f --- /dev/null +++ b/cmd/banger/main.go @@ -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) + } +} diff --git a/cmd/bangerd/main.go b/cmd/bangerd/main.go new file mode 100644 index 0000000..0cf8ab1 --- /dev/null +++ b/cmd/bangerd/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1ebcd6c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0af24cb --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..e2789d2 --- /dev/null +++ b/internal/api/types.go @@ -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"` +} diff --git a/internal/cli/banger.go b/internal/cli/banger.go new file mode 100644 index 0000000..433c47a --- /dev/null +++ b/internal/cli/banger.go @@ -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(¶ms.Name, "name", "", "vm name") + cmd.Flags().StringVar(¶ms.ImageName, "image", "", "image name or id") + cmd.Flags().IntVar(¶ms.VCPUCount, "vcpu", 0, "vcpu count") + cmd.Flags().IntVar(¶ms.MemoryMiB, "memory", 0, "memory in MiB") + cmd.Flags().StringVar(¶ms.SystemOverlaySize, "system-overlay-size", "", "system overlay size") + cmd.Flags().StringVar(¶ms.WorkDiskSize, "disk-size", "", "work disk size") + cmd.Flags().BoolVar(¶ms.NATEnabled, "nat", false, "enable NAT") + cmd.Flags().BoolVar(¶ms.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 ", + Short: "Show VM details", + Args: exactArgsUsage(1, "usage: banger vm show "), + 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 + " ", + Short: short, + Args: exactArgsUsage(1, fmt.Sprintf("usage: banger vm %s ", 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 ", + Short: "Update stopped VM settings", + Args: exactArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] "), + 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 [ssh args...]", + Short: "SSH into a running VM", + Args: minArgsUsage(1, "usage: banger vm ssh [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 ", + Short: "Show VM logs", + Args: exactArgsUsage(1, "usage: banger vm logs [-f] "), + 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 ", + Short: "Show VM stats", + Args: exactArgsUsage(1, "usage: banger vm stats "), + 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(¶ms.Name, "name", "", "image name") + cmd.Flags().StringVar(¶ms.BaseRootfs, "base-rootfs", "", "base rootfs path") + cmd.Flags().StringVar(¶ms.Size, "size", "", "output image size") + cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") + cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path") + cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") + cmd.Flags().BoolVar(¶ms.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 ", + Short: "Show image details", + Args: exactArgsUsage(1, "usage: banger image show "), + 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 ", + Short: "Delete an image", + Args: exactArgsUsage(1, "usage: banger image delete "), + 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))) + } +} diff --git a/internal/cli/bangerd.go b/internal/cli/bangerd.go new file mode 100644 index 0000000..13c55a1 --- /dev/null +++ b/internal/cli/bangerd.go @@ -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 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..5261343 --- /dev/null +++ b/internal/cli/cli_test.go @@ -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") + } +} diff --git a/internal/cli/tui.go b/internal/cli/tui.go new file mode 100644 index 0000000..9c56861 --- /dev/null +++ b/internal/cli/tui.go @@ -0,0 +1,1385 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "banger/internal/api" + "banger/internal/model" + "banger/internal/paths" + "banger/internal/rpc" + "banger/internal/system" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +const tuiRefreshInterval = 3 * time.Second + +type tuiMode int + +const ( + tuiModeBrowse tuiMode = iota + tuiModeForm + tuiModeConfirmDelete +) + +type actionKind string + +const ( + actionCreate actionKind = "create" + actionEdit actionKind = "edit" + actionStart actionKind = "start" + actionStop actionKind = "stop" + actionRestart actionKind = "restart" + actionDelete actionKind = "delete" + actionSSH actionKind = "ssh" + actionLogs actionKind = "logs" +) + +type dataLoadedMsg struct { + vms []model.VMRecord + images []model.Image + focusID string + err error +} + +type statsLoadedMsg struct { + id string + stats model.VMStats + err error +} + +type actionResultMsg struct { + action actionRequest + focusID string + status string + err error + refresh bool +} + +type externalPreparedMsg struct { + action actionRequest + command *exec.Cmd + doneStatus string + refresh bool + err error +} + +type sudoValidatedMsg struct { + err error +} + +type refreshTickMsg struct{} + +type actionRequest struct { + kind actionKind + id string + name string + create api.VMCreateParams + set api.VMSetParams +} + +type browseKeyMap struct { + refresh key.Binding + create key.Binding + edit key.Binding + start key.Binding + stop key.Binding + restart key.Binding + delete key.Binding + ssh key.Binding + logs key.Binding + help key.Binding + quit key.Binding +} + +func newBrowseKeyMap() browseKeyMap { + return browseKeyMap{ + refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + create: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")), + edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + start: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "start")), + stop: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "stop")), + restart: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "restart")), + delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + ssh: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "ssh")), + logs: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "logs")), + help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), + } +} + +func (k browseKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.refresh, k.create, k.edit, k.start, k.stop, k.delete, k.ssh, k.logs, k.quit} +} + +func (k browseKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.refresh, k.create, k.edit, k.start, k.stop, k.restart, k.delete}, + {k.ssh, k.logs, k.help, k.quit}, + } +} + +type formKeyMap struct { + next key.Binding + prev key.Binding + change key.Binding + toggle key.Binding + submit key.Binding + cancel key.Binding +} + +func newFormKeyMap() formKeyMap { + return formKeyMap{ + next: key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab", "next")), + prev: key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab", "prev")), + change: key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("left/right", "change")), + toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), + submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save")), + cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + } +} + +func (k formKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.next, k.change, k.toggle, k.submit, k.cancel} +} + +func (k formKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.next, k.prev, k.change}, {k.toggle, k.submit, k.cancel}} +} + +type confirmKeyMap struct { + confirm key.Binding + cancel key.Binding +} + +func newConfirmKeyMap() confirmKeyMap { + return confirmKeyMap{ + confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("enter", "confirm")), + cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("esc", "cancel")), + } +} + +func (k confirmKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.confirm, k.cancel} +} + +func (k confirmKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.confirm, k.cancel}} +} + +type formFieldKind int + +const ( + formFieldText formFieldKind = iota + formFieldSelect +) + +type formField struct { + label string + kind formFieldKind + input textinput.Model + options []string + index int +} + +func newTextField(label, value string) formField { + input := textinput.New() + input.Prompt = "" + input.SetValue(value) + input.CharLimit = 128 + return formField{ + label: label, + kind: formFieldText, + input: input, + } +} + +func newSelectField(label string, options []string, index int) formField { + if len(options) == 0 { + options = []string{""} + index = 0 + } + if index < 0 || index >= len(options) { + index = 0 + } + return formField{ + label: label, + kind: formFieldSelect, + options: options, + index: index, + } +} + +func (f formField) value() string { + if f.kind == formFieldText { + return f.input.Value() + } + if len(f.options) == 0 { + return "" + } + return f.options[f.index] +} + +type vmForm struct { + mode actionKind + title string + submitLabel string + targetID string + fields []formField + focus int +} + +func newCreateVMForm(images []model.Image, cfg model.DaemonConfig) *vmForm { + imageOptions := imageNames(images) + selectedImage := 0 + if cfg.DefaultImageName != "" { + for i, name := range imageOptions { + if name == cfg.DefaultImageName { + selectedImage = i + break + } + } + } + form := &vmForm{ + mode: actionCreate, + title: "Create VM", + submitLabel: "Create", + fields: []formField{ + newTextField("Name", ""), + newSelectField("Image", imageOptions, selectedImage), + newTextField("VCPU", strconv.Itoa(model.DefaultVCPUCount)), + newTextField("Memory (MiB)", strconv.Itoa(model.DefaultMemoryMiB)), + newTextField("System Overlay", model.FormatSizeBytes(model.DefaultSystemOverlaySize)), + newTextField("Work Disk", model.FormatSizeBytes(model.DefaultWorkDiskSize)), + newSelectField("NAT Enabled", []string{"no", "yes"}, 0), + newSelectField("No Start", []string{"no", "yes"}, 0), + }, + } + form.focusField(0) + return form +} + +func newEditVMForm(vm model.VMRecord) *vmForm { + form := &vmForm{ + mode: actionEdit, + title: "Edit VM", + submitLabel: "Save", + targetID: vm.ID, + fields: []formField{ + newTextField("VCPU", strconv.Itoa(vm.Spec.VCPUCount)), + newTextField("Memory (MiB)", strconv.Itoa(vm.Spec.MemoryMiB)), + newTextField("Work Disk", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)), + newSelectField("NAT Enabled", []string{"no", "yes"}, boolToIndex(vm.Spec.NATEnabled)), + }, + } + form.focusField(0) + return form +} + +func (f *vmForm) focusField(index int) tea.Cmd { + if len(f.fields) == 0 { + f.focus = 0 + return nil + } + if f.focus >= 0 && f.focus < len(f.fields) && f.fields[f.focus].kind == formFieldText { + f.fields[f.focus].input.Blur() + } + f.focus = wrapIndex(index, len(f.fields)) + if f.fields[f.focus].kind == formFieldText { + return f.fields[f.focus].input.Focus() + } + return nil +} + +func (f *vmForm) move(delta int) tea.Cmd { + return f.focusField(f.focus + delta) +} + +func (f *vmForm) setWidth(width int) { + inputWidth := maxInt(12, width-22) + for i := range f.fields { + if f.fields[i].kind == formFieldText { + f.fields[i].input.Width = inputWidth + } + } +} + +func (f *vmForm) update(msg tea.Msg) tea.Cmd { + if len(f.fields) == 0 { + return nil + } + if f.fields[f.focus].kind != formFieldText { + return nil + } + var cmd tea.Cmd + f.fields[f.focus].input, cmd = f.fields[f.focus].input.Update(msg) + return cmd +} + +func (f *vmForm) change(delta int) { + if len(f.fields) == 0 { + return + } + field := &f.fields[f.focus] + if field.kind != formFieldSelect || len(field.options) == 0 { + return + } + field.index = wrapIndex(field.index+delta, len(field.options)) +} + +func (f *vmForm) view(width int) string { + f.setWidth(width) + titleStyle := lipgloss.NewStyle().Bold(true) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) + valueStyle := lipgloss.NewStyle().Bold(true) + + lines := []string{titleStyle.Render(f.title), ""} + for i := range f.fields { + marker := " " + lbl := labelStyle.Render(f.fields[i].label) + if i == f.focus { + marker = "> " + lbl = activeStyle.Render(f.fields[i].label) + } + value := "" + if f.fields[i].kind == formFieldText { + value = f.fields[i].input.View() + } else { + value = valueStyle.Render(f.fields[i].value()) + } + lines = append(lines, fmt.Sprintf("%s%-16s %s", marker, lbl, value)) + } + lines = append(lines, "", fmt.Sprintf("Enter %s Esc cancel", strings.ToLower(f.submitLabel))) + + style := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + Padding(1, 2). + Width(maxInt(36, width)) + return style.Render(strings.Join(lines, "\n")) +} + +func (f *vmForm) submit() (actionRequest, error) { + switch f.mode { + case actionCreate: + return f.createRequest() + case actionEdit: + return f.editRequest() + default: + return actionRequest{}, errors.New("unsupported form mode") + } +} + +func (f *vmForm) createRequest() (actionRequest, error) { + vcpu, err := parsePositiveInt("vcpu", f.fields[2].value()) + if err != nil { + return actionRequest{}, err + } + memory, err := parsePositiveInt("memory", f.fields[3].value()) + if err != nil { + return actionRequest{}, err + } + params := api.VMCreateParams{ + Name: strings.TrimSpace(f.fields[0].value()), + ImageName: strings.TrimSpace(f.fields[1].value()), + VCPUCount: vcpu, + MemoryMiB: memory, + SystemOverlaySize: strings.TrimSpace(f.fields[4].value()), + WorkDiskSize: strings.TrimSpace(f.fields[5].value()), + NATEnabled: isYes(f.fields[6].value()), + NoStart: isYes(f.fields[7].value()), + } + if params.ImageName == "" || params.ImageName == "" { + return actionRequest{}, errors.New("create requires an image") + } + return actionRequest{kind: actionCreate, create: params}, nil +} + +func (f *vmForm) editRequest() (actionRequest, error) { + vcpu, err := parsePositiveInt("vcpu", f.fields[0].value()) + if err != nil { + return actionRequest{}, err + } + memory, err := parsePositiveInt("memory", f.fields[1].value()) + if err != nil { + return actionRequest{}, err + } + params, err := vmSetParamsFromFlags( + f.targetID, + vcpu, + memory, + strings.TrimSpace(f.fields[2].value()), + isYes(f.fields[3].value()), + false, + ) + if err != nil { + return actionRequest{}, err + } + return actionRequest{ + kind: actionEdit, + id: f.targetID, + set: params, + }, nil +} + +type tuiModel struct { + layout paths.Layout + cfg model.DaemonConfig + + width int + height int + ready bool + + loading bool + busy string + sudoValidated bool + + mode tuiMode + form *vmForm + pendingAction *actionRequest + + vms []model.VMRecord + images []model.Image + selectedID string + selectedStats model.VMStats + statsID string + statsErr string + + table table.Model + detail viewport.Model + help help.Model + spinner spinner.Model + + browseKeys browseKeyMap + formKeys formKeyMap + confirmKeys confirmKeyMap + + lastRefresh time.Time + statusText string + statusErr bool +} + +func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel { + vmTable := table.New( + table.WithColumns([]table.Column{ + {Title: "NAME", Width: 18}, + {Title: "STATE", Width: 9}, + {Title: "IP", Width: 14}, + {Title: "VCPU", Width: 4}, + {Title: "MEM", Width: 8}, + {Title: "DISK", Width: 8}, + {Title: "AGE", Width: 12}, + }), + table.WithRows(nil), + table.WithFocused(true), + table.WithHeight(10), + table.WithWidth(60), + table.WithKeyMap(tuiTableKeyMap()), + ) + tableStyles := table.DefaultStyles() + tableStyles.Header = lipgloss.NewStyle().Bold(true).Padding(0, 1) + tableStyles.Cell = lipgloss.NewStyle().Padding(0, 1) + tableStyles.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("62")) + vmTable.SetStyles(tableStyles) + + detail := viewport.New(0, 0) + detail.Style = lipgloss.NewStyle() + + spin := spinner.New(spinner.WithSpinner(spinner.Line)) + spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) + + helpView := help.New() + + return tuiModel{ + layout: layout, + cfg: cfg, + table: vmTable, + detail: detail, + help: helpView, + spinner: spin, + browseKeys: newBrowseKeyMap(), + formKeys: newFormKeyMap(), + confirmKeys: newConfirmKeyMap(), + loading: true, + statusText: "Loading VMs...", + } +} + +func newTUICommand() *cobra.Command { + return &cobra.Command{ + Use: "tui", + Short: "Launch a terminal UI to manage VMs", + Args: noArgsUsage("usage: banger tui"), + RunE: func(cmd *cobra.Command, args []string) error { + if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stdout.Fd()) { + return errors.New("tui requires an interactive terminal") + } + layout, cfg, err := ensureDaemon(cmd.Context()) + if err != nil { + return err + } + program := tea.NewProgram(newTUIModel(layout, cfg), tea.WithAltScreen()) + return program.Start() + }, + } +} + +func (m tuiModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, refreshTickCmd(), fetchDataCmd(m.layout, "")) +} + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + if m.loading || m.busy != "" { + var spinCmd tea.Cmd + m.spinner, spinCmd = m.spinner.Update(msg) + cmds = append(cmds, spinCmd) + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.ready = true + m.width = msg.Width + m.height = msg.Height + m.resize() + case dataLoadedMsg: + m.loading = false + if msg.err != nil { + m.setStatus(msg.err.Error(), true) + break + } + m.images = msg.images + m.vms = msg.vms + targetID := m.selectedID + if msg.focusID != "" { + targetID = msg.focusID + } + m.selectedID = resolveSelectedID(targetID, msg.vms) + m.lastRefresh = time.Now() + m.rebuildTable() + m.setStatus(fmt.Sprintf("Loaded %d VM(s)", len(msg.vms)), false) + if m.selectedID != "" { + cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID)) + } else { + m.selectedStats = model.VMStats{} + m.statsID = "" + m.statsErr = "" + } + case statsLoadedMsg: + if msg.id != m.selectedID { + break + } + if msg.err != nil { + m.statsErr = msg.err.Error() + m.setStatus(msg.err.Error(), true) + break + } + m.selectedStats = msg.stats + m.statsID = msg.id + m.statsErr = "" + case actionResultMsg: + m.busy = "" + if msg.err != nil { + if looksLikeSudoExpiry(msg.err) { + m.sudoValidated = false + } + m.setStatus(msg.err.Error(), true) + break + } + if msg.action.kind == actionCreate || msg.action.kind == actionEdit { + m.form = nil + m.mode = tuiModeBrowse + } + if msg.action.kind == actionDelete { + m.mode = tuiModeBrowse + } + m.setStatus(msg.status, false) + if msg.refresh { + m.loading = true + cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, msg.focusID)) + } + case externalPreparedMsg: + if msg.err != nil { + m.setStatus(msg.err.Error(), true) + break + } + cmds = append(cmds, tea.ExecProcess(msg.command, func(err error) tea.Msg { + return actionResultMsg{ + action: msg.action, + status: msg.doneStatus, + err: normalizeExecError(err), + refresh: msg.refresh, + focusID: m.selectedID, + } + })) + case sudoValidatedMsg: + if msg.err != nil { + m.pendingAction = nil + m.busy = "" + m.setStatus(msg.err.Error(), true) + break + } + m.sudoValidated = true + if m.pendingAction != nil { + action := *m.pendingAction + m.pendingAction = nil + m.busy = action.activity() + cmds = append(cmds, m.spinner.Tick, m.runActionCmd(action)) + } + case refreshTickMsg: + cmds = append(cmds, refreshTickCmd()) + if m.busy == "" && m.mode == tuiModeBrowse { + m.loading = true + cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID)) + } + case tea.KeyMsg: + switch m.mode { + case tuiModeBrowse: + nextModel, extraCmds := m.updateBrowse(msg) + m = nextModel + cmds = append(cmds, extraCmds...) + case tuiModeForm: + nextModel, extraCmds := m.updateForm(msg) + m = nextModel + cmds = append(cmds, extraCmds...) + case tuiModeConfirmDelete: + nextModel, extraCmds := m.updateConfirmDelete(msg) + m = nextModel + cmds = append(cmds, extraCmds...) + } + } + + m.refreshDetail() + return m, tea.Batch(cmds...) +} + +func (m tuiModel) updateBrowse(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { + var cmds []tea.Cmd + switch { + case key.Matches(msg, m.browseKeys.quit): + cmds = append(cmds, tea.Quit) + case key.Matches(msg, m.browseKeys.help): + m.help.ShowAll = !m.help.ShowAll + case key.Matches(msg, m.browseKeys.refresh): + m.loading = true + cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID)) + case key.Matches(msg, m.browseKeys.create): + if len(m.images) == 0 { + m.setStatus("Create requires at least one image", true) + return m, cmds + } + m.form = newCreateVMForm(m.images, m.cfg) + m.mode = tuiModeForm + cmds = append(cmds, m.form.focusField(m.form.focus)) + case key.Matches(msg, m.browseKeys.edit): + vm, ok := m.selectedVM() + if !ok { + m.setStatus("No VM selected", true) + return m, cmds + } + if vm.State == model.VMStateRunning { + m.setStatus("Stop the VM before editing it", true) + return m, cmds + } + m.form = newEditVMForm(vm) + m.mode = tuiModeForm + cmds = append(cmds, m.form.focusField(m.form.focus)) + case key.Matches(msg, m.browseKeys.delete): + if _, ok := m.selectedVM(); !ok { + m.setStatus("No VM selected", true) + return m, cmds + } + m.mode = tuiModeConfirmDelete + case key.Matches(msg, m.browseKeys.start): + vm, ok := m.selectedVM() + if !ok { + m.setStatus("No VM selected", true) + return m, cmds + } + if vm.State == model.VMStateRunning { + m.setStatus("VM is already running", true) + return m, cmds + } + cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStart, id: vm.ID, name: vm.Name})) + case key.Matches(msg, m.browseKeys.stop): + vm, ok := m.selectedVM() + if !ok { + m.setStatus("No VM selected", true) + return m, cmds + } + if vm.State != model.VMStateRunning { + m.setStatus("VM is not running", true) + return m, cmds + } + cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStop, id: vm.ID, name: vm.Name})) + case key.Matches(msg, m.browseKeys.restart): + vm, ok := m.selectedVM() + if !ok { + m.setStatus("No VM selected", true) + return m, cmds + } + cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionRestart, id: vm.ID, name: vm.Name})) + case key.Matches(msg, m.browseKeys.ssh): + vm, ok := m.selectedVM() + if !ok { + m.setStatus("No VM selected", true) + return m, cmds + } + if vm.State != model.VMStateRunning { + m.setStatus("SSH requires a running VM", true) + return m, cmds + } + cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionSSH, id: vm.ID, name: vm.Name})) + case key.Matches(msg, m.browseKeys.logs): + vm, ok := m.selectedVM() + if !ok { + m.setStatus("No VM selected", true) + return m, cmds + } + cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionLogs, id: vm.ID, name: vm.Name})) + default: + if len(m.vms) == 0 { + return m, cmds + } + oldCursor := m.table.Cursor() + var tableCmd tea.Cmd + m.table, tableCmd = m.table.Update(msg) + cmds = append(cmds, tableCmd) + if m.table.Cursor() != oldCursor { + if vm, ok := m.selectedVMByCursor(); ok { + m.selectedID = vm.ID + m.selectedStats = model.VMStats{} + m.statsID = "" + m.statsErr = "" + cmds = append(cmds, fetchStatsCmd(m.layout, vm.ID)) + } + } + } + return m, cmds +} + +func (m tuiModel) updateForm(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { + var cmds []tea.Cmd + switch { + case key.Matches(msg, m.formKeys.cancel): + m.form = nil + m.mode = tuiModeBrowse + case key.Matches(msg, m.formKeys.next): + cmds = append(cmds, m.form.move(1)) + case key.Matches(msg, m.formKeys.prev): + cmds = append(cmds, m.form.move(-1)) + case key.Matches(msg, m.formKeys.change): + switch msg.String() { + case "left": + m.form.change(-1) + case "right": + m.form.change(1) + } + case key.Matches(msg, m.formKeys.toggle): + m.form.change(1) + case key.Matches(msg, m.formKeys.submit): + action, err := m.form.submit() + if err != nil { + m.setStatus(err.Error(), true) + return m, cmds + } + cmds = append(cmds, m.dispatchAction(action)) + default: + cmds = append(cmds, m.form.update(msg)) + } + return m, cmds +} + +func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { + switch { + case key.Matches(msg, m.confirmKeys.cancel): + m.mode = tuiModeBrowse + return m, nil + case key.Matches(msg, m.confirmKeys.confirm): + vm, ok := m.selectedVM() + if !ok { + m.mode = tuiModeBrowse + m.setStatus("No VM selected", true) + return m, nil + } + return m, []tea.Cmd{m.dispatchAction(actionRequest{kind: actionDelete, id: vm.ID, name: vm.Name})} + default: + return m, nil + } +} + +func (m tuiModel) View() string { + if !m.ready { + return "Loading..." + } + header := m.renderHeader() + body := m.renderBody() + status := m.renderStatus() + m.help.Width = m.width + helpView := m.help.View(m.currentKeyMap()) + return lipgloss.JoinVertical(lipgloss.Left, header, body, status, helpView) +} + +func (m tuiModel) currentKeyMap() help.KeyMap { + switch m.mode { + case tuiModeForm: + return m.formKeys + case tuiModeConfirmDelete: + return m.confirmKeys + default: + return m.browseKeys + } +} + +func (m *tuiModel) resize() { + bodyHeight := m.bodyHeight() + leftWidth := maxInt(42, (m.width*55)/100) + if leftWidth > m.width-24 { + leftWidth = maxInt(24, m.width/2) + } + rightWidth := maxInt(24, m.width-leftWidth-1) + + leftInnerWidth := maxInt(20, leftWidth-4) + rightInnerWidth := maxInt(20, rightWidth-4) + panelInnerHeight := maxInt(8, bodyHeight-2) + + m.table.SetWidth(leftInnerWidth) + m.table.SetHeight(maxInt(4, panelInnerHeight-2)) + m.detail.Width = rightInnerWidth + m.detail.Height = maxInt(4, panelInnerHeight-2) + if m.form != nil { + m.form.setWidth(maxInt(28, minInt(58, m.width-10))) + } +} + +func (m tuiModel) bodyHeight() int { + return maxInt(8, m.height-4) +} + +func (m tuiModel) renderHeader() string { + status := "idle" + if m.loading { + status = m.spinner.View() + " loading" + } else if m.busy != "" { + status = m.spinner.View() + " " + m.busy + } + refreshed := "never" + if !m.lastRefresh.IsZero() { + refreshed = relativeTime(m.lastRefresh) + } + header := fmt.Sprintf("banger tui socket %s %s last refresh %s", filepath.Base(m.layout.SocketPath), status, refreshed) + return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header) +} + +func (m tuiModel) renderBody() string { + bodyHeight := m.bodyHeight() + if m.mode == tuiModeForm && m.form != nil { + return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.form.view(minInt(58, m.width-10))) + } + if m.mode == tuiModeConfirmDelete { + return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.confirmDeleteView()) + } + + leftWidth := maxInt(42, (m.width*55)/100) + if leftWidth > m.width-24 { + leftWidth = maxInt(24, m.width/2) + } + rightWidth := maxInt(24, m.width-leftWidth-1) + leftInnerWidth := maxInt(20, leftWidth-4) + rightInnerWidth := maxInt(20, rightWidth-4) + panelHeight := maxInt(8, bodyHeight-1) + + leftContent := m.table.View() + if len(m.vms) == 0 { + leftContent = "No VMs.\n\nPress c to create one." + } + leftPanel := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + Padding(0, 1). + Width(leftWidth). + Height(panelHeight). + Render(lipgloss.NewStyle().Width(leftInnerWidth).Render(leftContent)) + + rightPanel := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + Padding(0, 1). + Width(rightWidth). + Height(panelHeight). + Render(lipgloss.NewStyle().Width(rightInnerWidth).Render(m.detail.View())) + + return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) +} + +func (m tuiModel) confirmDeleteView() string { + vm, ok := m.selectedVM() + name := "this VM" + if ok { + name = vm.Name + } + content := fmt.Sprintf("Delete %s?\n\nThis removes the VM and its persistent state.\n\nEnter confirm Esc cancel", name) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + Padding(1, 2). + Width(54). + Render(content) +} + +func (m tuiModel) renderStatus() string { + if m.statusText == "" { + return " " + } + style := lipgloss.NewStyle().Foreground(lipgloss.Color("70")) + if m.statusErr { + style = lipgloss.NewStyle().Foreground(lipgloss.Color("160")) + } + return style.Width(m.width).Render(m.statusText) +} + +func (m *tuiModel) rebuildTable() { + rows := make([]table.Row, 0, len(m.vms)) + cursor := 0 + for i, vm := range m.vms { + rows = append(rows, table.Row{ + vm.Name, + string(vm.State), + vm.Runtime.GuestIP, + strconv.Itoa(vm.Spec.VCPUCount), + fmt.Sprintf("%dM", vm.Spec.MemoryMiB), + model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), + relativeTime(vm.CreatedAt), + }) + if vm.ID == m.selectedID { + cursor = i + } + } + m.table.SetRows(rows) + if len(rows) > 0 { + m.table.SetCursor(cursor) + } +} + +func (m *tuiModel) refreshDetail() { + vm, ok := m.selectedVM() + if !ok { + m.detail.SetContent("No VM selected.\n\nUse c to create a VM.") + return + } + stats := vm.Stats + if m.statsID == vm.ID && !m.selectedStats.CollectedAt.IsZero() { + stats = m.selectedStats + } + lines := []string{ + fmt.Sprintf("Name: %s", vm.Name), + fmt.Sprintf("State: %s", vm.State), + fmt.Sprintf("IP: %s", orDash(vm.Runtime.GuestIP)), + fmt.Sprintf("DNS: %s", orDash(vm.Runtime.DNSName)), + fmt.Sprintf("Image: %s", shortID(vm.ImageID)), + "", + "Config", + fmt.Sprintf(" vCPU: %d", vm.Spec.VCPUCount), + fmt.Sprintf(" Memory: %d MiB", vm.Spec.MemoryMiB), + fmt.Sprintf(" Overlay: %s", model.FormatSizeBytes(vm.Spec.SystemOverlaySizeByte)), + fmt.Sprintf(" Work disk: %s", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)), + fmt.Sprintf(" NAT: %s", yesNo(vm.Spec.NATEnabled)), + "", + "Usage", + fmt.Sprintf(" Overlay: %s", formatBytes(stats.SystemOverlayBytes)), + fmt.Sprintf(" Work disk: %s", formatBytes(stats.WorkDiskBytes)), + fmt.Sprintf(" CPU: %.1f%%", stats.CPUPercent), + fmt.Sprintf(" RSS: %s", formatBytes(stats.RSSBytes)), + fmt.Sprintf(" VSZ: %s", formatBytes(stats.VSZBytes)), + } + if !stats.CollectedAt.IsZero() { + lines = append(lines, fmt.Sprintf(" Updated: %s", stats.CollectedAt.Format(time.RFC3339))) + } + lines = append(lines, + "", + "Timestamps", + fmt.Sprintf(" Created: %s", vm.CreatedAt.Format(time.RFC3339)), + fmt.Sprintf(" Updated: %s", vm.UpdatedAt.Format(time.RFC3339)), + fmt.Sprintf(" Touched: %s", vm.LastTouchedAt.Format(time.RFC3339)), + ) + if vm.Runtime.LastError != "" { + lines = append(lines, "", "Last error", " "+vm.Runtime.LastError) + } + if m.statsErr != "" && m.statsID == vm.ID { + lines = append(lines, "", "Stats error", " "+m.statsErr) + } + m.detail.SetContent(strings.Join(lines, "\n")) +} + +func (m *tuiModel) setStatus(text string, isErr bool) { + m.statusText = text + m.statusErr = isErr +} + +func (m tuiModel) selectedVM() (model.VMRecord, bool) { + for _, vm := range m.vms { + if vm.ID == m.selectedID { + return vm, true + } + } + return model.VMRecord{}, false +} + +func (m tuiModel) selectedVMByCursor() (model.VMRecord, bool) { + cursor := m.table.Cursor() + if cursor < 0 || cursor >= len(m.vms) { + return model.VMRecord{}, false + } + return m.vms[cursor], true +} + +func (m *tuiModel) dispatchAction(action actionRequest) tea.Cmd { + if action.needsSudo() && !m.sudoValidated { + m.pendingAction = &action + m.busy = "Authorizing sudo..." + return tea.ExecProcess(exec.Command("sudo", "-v"), func(err error) tea.Msg { + return sudoValidatedMsg{err: err} + }) + } + m.busy = action.activity() + return tea.Batch(m.spinner.Tick, m.runActionCmd(action)) +} + +func (m tuiModel) runActionCmd(action actionRequest) tea.Cmd { + switch action.kind { + case actionCreate: + return createActionCmd(m.layout, action) + case actionEdit: + return editActionCmd(m.layout, action) + case actionStart, actionStop, actionRestart: + return lifecycleActionCmd(m.layout, action) + case actionDelete: + return deleteActionCmd(m.layout, action) + case actionSSH: + return prepareSSHCmd(m.layout, m.cfg, action) + case actionLogs: + return prepareLogsCmd(m.layout, action) + default: + return func() tea.Msg { + return actionResultMsg{action: action, err: fmt.Errorf("unsupported action %s", action.kind)} + } + } +} + +func createActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { + return func() tea.Msg { + result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.create", action.create) + if err != nil { + return actionResultMsg{action: action, err: err} + } + return actionResultMsg{ + action: action, + focusID: result.VM.ID, + status: fmt.Sprintf("created %s", result.VM.Name), + refresh: true, + } + } +} + +func editActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { + return func() tea.Msg { + result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.set", action.set) + if err != nil { + return actionResultMsg{action: action, err: err} + } + return actionResultMsg{ + action: action, + focusID: result.VM.ID, + status: fmt.Sprintf("updated %s", result.VM.Name), + refresh: true, + } + } +} + +func lifecycleActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { + method := "" + status := "" + switch action.kind { + case actionStart: + method = "vm.start" + status = "started" + case actionStop: + method = "vm.stop" + status = "stopped" + case actionRestart: + method = "vm.restart" + status = "restarted" + } + return func() tea.Msg { + result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, method, api.VMRefParams{IDOrName: action.id}) + if err != nil { + return actionResultMsg{action: action, err: err} + } + return actionResultMsg{ + action: action, + focusID: result.VM.ID, + status: fmt.Sprintf("%s %s", status, result.VM.Name), + refresh: true, + } + } +} + +func deleteActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { + return func() tea.Msg { + _, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.delete", api.VMRefParams{IDOrName: action.id}) + if err != nil { + return actionResultMsg{action: action, err: err} + } + return actionResultMsg{ + action: action, + status: fmt.Sprintf("deleted %s", action.name), + refresh: true, + } + } +} + +func prepareSSHCmd(layout paths.Layout, cfg model.DaemonConfig, action actionRequest) tea.Cmd { + return func() tea.Msg { + result, err := rpc.Call[api.VMSSHResult](context.Background(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: action.id}) + if err != nil { + return externalPreparedMsg{action: action, err: err} + } + args, err := sshCommandArgs(cfg, result.GuestIP, nil) + if err != nil { + return externalPreparedMsg{action: action, err: err} + } + return externalPreparedMsg{ + action: action, + command: exec.Command("ssh", args...), + doneStatus: fmt.Sprintf("ssh session ended for %s", result.Name), + refresh: true, + } + } +} + +func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd { + return func() tea.Msg { + result, err := rpc.Call[api.VMLogsResult](context.Background(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: action.id}) + if err != nil { + return externalPreparedMsg{action: action, err: err} + } + if result.LogPath == "" { + return externalPreparedMsg{action: action, err: errors.New("vm has no log path")} + } + return externalPreparedMsg{ + action: action, + command: system.TailCommand(result.LogPath, true), + doneStatus: fmt.Sprintf("closed log view for %s", action.name), + refresh: false, + } + } +} + +func fetchDataCmd(layout paths.Layout, focusID string) tea.Cmd { + return func() tea.Msg { + vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{}) + if err != nil { + return dataLoadedMsg{err: err, focusID: focusID} + } + images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{}) + if err != nil { + return dataLoadedMsg{vms: vms.VMs, err: err, focusID: focusID} + } + return dataLoadedMsg{vms: vms.VMs, images: images.Images, focusID: focusID} + } +} + +func fetchStatsCmd(layout paths.Layout, id string) tea.Cmd { + return func() tea.Msg { + result, err := rpc.Call[api.VMStatsResult](context.Background(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: id}) + if err != nil { + return statsLoadedMsg{id: id, err: err} + } + return statsLoadedMsg{id: id, stats: result.Stats} + } +} + +func refreshTickCmd() tea.Cmd { + return tea.Tick(tuiRefreshInterval, func(time.Time) tea.Msg { + return refreshTickMsg{} + }) +} + +func resolveSelectedID(targetID string, vms []model.VMRecord) string { + if len(vms) == 0 { + return "" + } + if targetID != "" { + for _, vm := range vms { + if vm.ID == targetID { + return targetID + } + } + } + return vms[0].ID +} + +func imageNames(images []model.Image) []string { + names := make([]string, 0, len(images)) + for _, image := range images { + names = append(names, image.Name) + } + return names +} + +func tuiTableKeyMap() table.KeyMap { + return table.KeyMap{ + LineUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up", "up")), + LineDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down", "down")), + PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), + PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), + HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "half up")), + HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "half down")), + GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "top")), + GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "bottom")), + } +} + +func (a actionRequest) needsSudo() bool { + switch a.kind { + case actionCreate, actionEdit, actionStart, actionStop, actionRestart, actionDelete: + return true + default: + return false + } +} + +func (a actionRequest) activity() string { + switch a.kind { + case actionCreate: + return "Creating VM..." + case actionEdit: + return "Saving VM..." + case actionStart: + return "Starting VM..." + case actionStop: + return "Stopping VM..." + case actionRestart: + return "Restarting VM..." + case actionDelete: + return "Deleting VM..." + case actionSSH: + return "Opening SSH..." + case actionLogs: + return "Opening logs..." + default: + return "Working..." + } +} + +func parsePositiveInt(label, raw string) (int, error) { + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || value <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", label) + } + return value, nil +} + +func normalizeExecError(err error) error { + if err == nil { + return nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 130 { + return nil + } + return err +} + +func looksLikeSudoExpiry(err error) bool { + if err == nil { + return false + } + text := err.Error() + return strings.Contains(text, "sudo") || strings.Contains(text, "password is required") +} + +func formatBytes(bytes int64) string { + if bytes <= 0 { + return "0" + } + const ( + kib = 1024 + mib = 1024 * kib + gib = 1024 * mib + ) + switch { + case bytes >= gib: + return fmt.Sprintf("%.1fG", float64(bytes)/gib) + case bytes >= mib: + return fmt.Sprintf("%.1fM", float64(bytes)/mib) + case bytes >= kib: + return fmt.Sprintf("%.1fK", float64(bytes)/kib) + default: + return strconv.FormatInt(bytes, 10) + "B" + } +} + +func boolToIndex(value bool) int { + if value { + return 1 + } + return 0 +} + +func yesNo(value bool) string { + if value { + return "yes" + } + return "no" +} + +func isYes(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "yes") +} + +func orDash(value string) string { + if strings.TrimSpace(value) == "" { + return "-" + } + return value +} + +func wrapIndex(value, length int) int { + if length <= 0 { + return 0 + } + for value < 0 { + value += length + } + return value % length +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go new file mode 100644 index 0000000..18e37f6 --- /dev/null +++ b/internal/cli/tui_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8cfd136 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..2a0eba3 --- /dev/null +++ b/internal/daemon/daemon.go @@ -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 +} diff --git a/internal/daemon/images.go b/internal/daemon/images.go new file mode 100644 index 0000000..7af675c --- /dev/null +++ b/internal/daemon/images.go @@ -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 +} diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go new file mode 100644 index 0000000..b93546b --- /dev/null +++ b/internal/daemon/vm.go @@ -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 +} diff --git a/internal/firecracker/client.go b/internal/firecracker/client.go new file mode 100644 index 0000000..036a9a8 --- /dev/null +++ b/internal/firecracker/client.go @@ -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 +} diff --git a/internal/model/types.go b/internal/model/types.go new file mode 100644 index 0000000..d6f242e --- /dev/null +++ b/internal/model/types.go @@ -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) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go new file mode 100644 index 0000000..6a4a81c --- /dev/null +++ b/internal/paths/paths.go @@ -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()) +} diff --git a/internal/rpc/rpc.go b/internal/rpc/rpc.go new file mode 100644 index 0000000..f9f77d8 --- /dev/null +++ b/internal/rpc/rpc.go @@ -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) + }, + }, + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..1696ee1 --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/system/system.go b/internal/system/system.go new file mode 100644 index 0000000..3f153cc --- /dev/null +++ b/internal/system/system.go @@ -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() +}