Add Go daemon-driven VM control plane
Replace the shell-only user workflow with `banger` and `bangerd`: Cobra commands, XDG/SQLite-backed state, managed VM and image lifecycle, and a Bubble Tea TUI for browsing and operating VMs.\n\nKeep Firecracker orchestration behind the daemon so VM specs become persistent objects, and add repo entrypoints for building, installing, and documenting the new flow while still delegating rootfs customization to the existing shell tooling.\n\nHarden the control plane around real usage by reclaiming Firecracker API sockets for the user, restarting stale daemons after rebuilds, and returning the correct `vm.create` payload so the CLI and TUI creation flow work reliably.\n\nValidation: `go test ./...`, `make build`, and a host-side smoke test with `./banger vm create --name codex-smoke`.
This commit is contained in:
parent
3cf33d1e0a
commit
ea72ea26fe
22 changed files with 5480 additions and 0 deletions
53
Makefile
Normal file
53
Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
SHELL := /usr/bin/env bash
|
||||
|
||||
GO ?= go
|
||||
GOFMT ?= gofmt
|
||||
INSTALL ?= install
|
||||
PREFIX ?= $(HOME)/.local
|
||||
BINDIR ?= $(PREFIX)/bin
|
||||
DESTDIR ?=
|
||||
BINARIES := banger bangerd
|
||||
GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort)
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help build banger bangerd test fmt tidy clean rootfs install
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
'Targets:' \
|
||||
' make build Build ./banger and ./bangerd' \
|
||||
' make install Build and install binaries into $(DESTDIR)$(BINDIR)' \
|
||||
' make test Run go test ./...' \
|
||||
' make fmt Format Go sources under cmd/ and internal/' \
|
||||
' make tidy Run go mod tidy' \
|
||||
' make clean Remove built Go binaries' \
|
||||
' make rootfs Run ./make-rootfs.sh'
|
||||
|
||||
build: $(BINARIES)
|
||||
|
||||
banger: $(GO_SOURCES) go.mod go.sum
|
||||
$(GO) build -o ./banger ./cmd/banger
|
||||
|
||||
bangerd: $(GO_SOURCES) go.mod go.sum
|
||||
$(GO) build -o ./bangerd ./cmd/bangerd
|
||||
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
||||
fmt:
|
||||
$(GOFMT) -w $(GO_SOURCES)
|
||||
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
|
||||
clean:
|
||||
rm -f ./banger ./bangerd
|
||||
|
||||
install: build
|
||||
mkdir -p "$(DESTDIR)$(BINDIR)"
|
||||
$(INSTALL) -m 0755 ./banger "$(DESTDIR)$(BINDIR)/banger"
|
||||
$(INSTALL) -m 0755 ./bangerd "$(DESTDIR)$(BINDIR)/bangerd"
|
||||
|
||||
rootfs:
|
||||
./make-rootfs.sh
|
||||
33
README.md
33
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
|
||||
|
|
|
|||
22
cmd/banger/main.go
Normal file
22
cmd/banger/main.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"banger/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cmd := cli.NewBangerCommand()
|
||||
if err := cmd.ExecuteContext(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "banger: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
22
cmd/bangerd/main.go
Normal file
22
cmd/bangerd/main.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"banger/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cmd := cli.NewBangerdCommand()
|
||||
if err := cmd.ExecuteContext(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "bangerd: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
37
go.mod
Normal file
37
go.mod
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
module banger
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.14.0
|
||||
github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873
|
||||
github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/pelletier/go-toml v1.9.5
|
||||
github.com/spf13/cobra v1.8.1
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/containerd/console v1.0.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
||||
github.com/muesli/cancelreader v0.2.1 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
104
go.sum
Normal file
104
go.sum
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/charmbracelet/bubbles v0.14.0 h1:DJfCwnARfWjZLvMglhSQzo76UZ2gucuHPy9jLWX45Og=
|
||||
github.com/charmbracelet/bubbles v0.14.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
|
||||
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
|
||||
github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873 h1:ti/1QRoSzanYHPW4jLgIjCkfJ3beXh2h1nr6nEkWOig=
|
||||
github.com/charmbracelet/bubbletea v0.21.1-0.20220623121936-ca32c4c62873/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
|
||||
github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43 h1:xO5Bh21Ii+0p3EYp1GdFEF/Iax7VhBgMbBVCOFBZ2/Q=
|
||||
github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
|
||||
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
|
||||
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
||||
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
|
||||
github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
|
||||
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
|
||||
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
81
internal/api/types.go
Normal file
81
internal/api/types.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package api
|
||||
|
||||
import "banger/internal/model"
|
||||
|
||||
type Empty struct{}
|
||||
|
||||
type PingResult struct {
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid"`
|
||||
}
|
||||
|
||||
type ShutdownResult struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type VMCreateParams struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
ImageName string `json:"image_name,omitempty"`
|
||||
VCPUCount int `json:"vcpu_count,omitempty"`
|
||||
MemoryMiB int `json:"memory_mib,omitempty"`
|
||||
SystemOverlaySize string `json:"system_overlay_size,omitempty"`
|
||||
WorkDiskSize string `json:"work_disk_size,omitempty"`
|
||||
NATEnabled bool `json:"nat_enabled,omitempty"`
|
||||
NoStart bool `json:"no_start,omitempty"`
|
||||
}
|
||||
|
||||
type VMRefParams struct {
|
||||
IDOrName string `json:"id_or_name"`
|
||||
}
|
||||
|
||||
type VMSetParams struct {
|
||||
IDOrName string `json:"id_or_name"`
|
||||
VCPUCount *int `json:"vcpu_count,omitempty"`
|
||||
MemoryMiB *int `json:"memory_mib,omitempty"`
|
||||
WorkDiskSize string `json:"work_disk_size,omitempty"`
|
||||
NATEnabled *bool `json:"nat_enabled,omitempty"`
|
||||
}
|
||||
|
||||
type VMListResult struct {
|
||||
VMs []model.VMRecord `json:"vms"`
|
||||
}
|
||||
|
||||
type VMShowResult struct {
|
||||
VM model.VMRecord `json:"vm"`
|
||||
}
|
||||
|
||||
type VMStatsResult struct {
|
||||
VM model.VMRecord `json:"vm"`
|
||||
Stats model.VMStats `json:"stats"`
|
||||
}
|
||||
|
||||
type VMLogsResult struct {
|
||||
LogPath string `json:"log_path"`
|
||||
}
|
||||
|
||||
type VMSSHResult struct {
|
||||
Name string `json:"name"`
|
||||
GuestIP string `json:"guest_ip"`
|
||||
}
|
||||
|
||||
type ImageBuildParams struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
BaseRootfs string `json:"base_rootfs,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
KernelPath string `json:"kernel_path,omitempty"`
|
||||
InitrdPath string `json:"initrd_path,omitempty"`
|
||||
ModulesDir string `json:"modules_dir,omitempty"`
|
||||
Docker bool `json:"docker,omitempty"`
|
||||
}
|
||||
|
||||
type ImageRefParams struct {
|
||||
IDOrName string `json:"id_or_name"`
|
||||
}
|
||||
|
||||
type ImageListResult struct {
|
||||
Images []model.Image `json:"images"`
|
||||
}
|
||||
|
||||
type ImageShowResult struct {
|
||||
Image model.Image `json:"image"`
|
||||
}
|
||||
708
internal/cli/banger.go
Normal file
708
internal/cli/banger.go
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/config"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/rpc"
|
||||
"banger/internal/system"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
bangerdPathFunc = paths.BangerdPath
|
||||
daemonExePath = func(pid int) string {
|
||||
return filepath.Join("/proc", fmt.Sprintf("%d", pid), "exe")
|
||||
}
|
||||
)
|
||||
|
||||
func NewBangerCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "banger",
|
||||
Short: "Manage development VMs and images",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
root.CompletionOptions.DisableDefaultCmd = true
|
||||
root.AddCommand(newDaemonCommand(), newVMCommand(), newImageCommand(), newTUICommand())
|
||||
return root
|
||||
}
|
||||
|
||||
func newDaemonCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Manage the banger daemon",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
&cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show daemon status",
|
||||
Args: noArgsUsage("usage: banger daemon status"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ping, pingErr := rpc.Call[api.PingResult](cmd.Context(), layout.SocketPath, "ping", api.Empty{})
|
||||
if pingErr != nil {
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\n", layout.SocketPath)
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\n", ping.PID, layout.SocketPath)
|
||||
return err
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the daemon",
|
||||
Args: noArgsUsage("usage: banger daemon stop"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = rpc.Call[api.ShutdownResult](cmd.Context(), layout.SocketPath, "shutdown", api.Empty{})
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) || strings.Contains(err.Error(), "connect") {
|
||||
_, writeErr := fmt.Fprintln(cmd.OutOrStdout(), "daemon not running")
|
||||
return writeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), "stopping")
|
||||
return err
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "socket",
|
||||
Short: "Print the daemon socket path",
|
||||
Args: noArgsUsage("usage: banger daemon socket"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), layout.SocketPath)
|
||||
return err
|
||||
},
|
||||
},
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "vm",
|
||||
Short: "Manage virtual machines",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newVMCreateCommand(),
|
||||
newVMListCommand(),
|
||||
newVMShowCommand(),
|
||||
newVMActionCommand("start", "Start a VM", "vm.start"),
|
||||
newVMActionCommand("stop", "Stop a VM", "vm.stop"),
|
||||
newVMActionCommand("restart", "Restart a VM", "vm.restart"),
|
||||
newVMActionCommand("delete", "Delete a VM", "vm.delete"),
|
||||
newVMSetCommand(),
|
||||
newVMSSHCommand(),
|
||||
newVMLogsCommand(),
|
||||
newVMStatsCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMCreateCommand() *cobra.Command {
|
||||
var params api.VMCreateParams
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a VM",
|
||||
Args: noArgsUsage("usage: banger vm create"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.create", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printVMSummary(cmd.OutOrStdout(), result.VM)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(¶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 <id-or-name>",
|
||||
Short: "Show VM details",
|
||||
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(cmd.OutOrStdout(), result.VM)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMActionCommand(use, short, method string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: use + " <id-or-name>",
|
||||
Short: short,
|
||||
Args: exactArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>", use)),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, method, api.VMRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printVMSummary(cmd.OutOrStdout(), result.VM)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSetCommand() *cobra.Command {
|
||||
var (
|
||||
vcpu int
|
||||
memory int
|
||||
diskSize string
|
||||
nat bool
|
||||
noNat bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "set <id-or-name>",
|
||||
Short: "Update stopped VM settings",
|
||||
Args: exactArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.set", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printVMSummary(cmd.OutOrStdout(), result.VM)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&vcpu, "vcpu", -1, "vcpu count")
|
||||
cmd.Flags().IntVar(&memory, "memory", -1, "memory in MiB")
|
||||
cmd.Flags().StringVar(&diskSize, "disk-size", "", "new work disk size")
|
||||
cmd.Flags().BoolVar(&nat, "nat", false, "enable NAT")
|
||||
cmd.Flags().BoolVar(&noNat, "no-nat", false, "disable NAT")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSSHCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "ssh <id-or-name> [ssh args...]",
|
||||
Short: "SSH into a running VM",
|
||||
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, cfg, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.VMSSHResult](cmd.Context(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sshArgs, err := sshCommandArgs(cfg, result.GuestIP, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sshCmd := exec.CommandContext(cmd.Context(), "ssh", sshArgs...)
|
||||
sshCmd.Stdout = cmd.OutOrStdout()
|
||||
sshCmd.Stderr = cmd.ErrOrStderr()
|
||||
sshCmd.Stdin = cmd.InOrStdin()
|
||||
return sshCmd.Run()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMLogsCommand() *cobra.Command {
|
||||
var follow bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs <id-or-name>",
|
||||
Short: "Show VM logs",
|
||||
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.VMLogsResult](cmd.Context(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.LogPath == "" {
|
||||
return errors.New("vm has no log path")
|
||||
}
|
||||
return system.CopyStream(cmd.OutOrStdout(), system.TailCommand(result.LogPath, follow))
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow logs")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMStatsCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stats <id-or-name>",
|
||||
Short: "Show VM stats",
|
||||
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.VMStatsResult](cmd.Context(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(cmd.OutOrStdout(), result)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newImageCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "image",
|
||||
Short: "Manage images",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newImageBuildCommand(),
|
||||
newImageListCommand(),
|
||||
newImageShowCommand(),
|
||||
newImageDeleteCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newImageBuildCommand() *cobra.Command {
|
||||
var params api.ImageBuildParams
|
||||
cmd := &cobra.Command{
|
||||
Use: "build",
|
||||
Short: "Build an image",
|
||||
Args: noArgsUsage("usage: banger image build"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.build", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printImageSummary(cmd.OutOrStdout(), result.Image)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(¶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 <id-or-name>",
|
||||
Short: "Show image details",
|
||||
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.show", api.ImageRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(cmd.OutOrStdout(), result.Image)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newImageDeleteCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <id-or-name>",
|
||||
Short: "Delete an image",
|
||||
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.delete", api.ImageRefParams{IDOrName: args[0]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printImageSummary(cmd.OutOrStdout(), result.Image)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func helpNoArgs(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != 0 {
|
||||
return fmt.Errorf("unknown arguments: %s", strings.Join(args, " "))
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
func noArgsUsage(usage string) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != 0 {
|
||||
return errors.New(usage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func exactArgsUsage(n int, usage string) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != n {
|
||||
return errors.New(usage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func minArgsUsage(n int, usage string) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < n {
|
||||
return errors.New(usage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, err
|
||||
}
|
||||
cfg, err := config.Load(layout)
|
||||
if err != nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, err
|
||||
}
|
||||
if ping, err := rpc.Call[api.PingResult](ctx, layout.SocketPath, "ping", api.Empty{}); err == nil {
|
||||
if daemonOutdated(ping.PID) {
|
||||
if err := restartDaemon(ctx, layout, ping.PID); err != nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, err
|
||||
}
|
||||
return layout, cfg, nil
|
||||
}
|
||||
return layout, cfg, nil
|
||||
}
|
||||
if err := startDaemon(ctx, layout); err != nil {
|
||||
return paths.Layout{}, model.DaemonConfig{}, err
|
||||
}
|
||||
return layout, cfg, nil
|
||||
}
|
||||
|
||||
func daemonOutdated(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
daemonBin, err := bangerdPathFunc()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
currentInfo, err := os.Stat(daemonBin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
runningInfo, err := os.Stat(daemonExePath(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !os.SameFile(currentInfo, runningInfo)
|
||||
}
|
||||
|
||||
func restartDaemon(ctx context.Context, layout paths.Layout, pid int) error {
|
||||
stopCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, _ = rpc.Call[api.ShutdownResult](stopCtx, layout.SocketPath, "shutdown", api.Empty{})
|
||||
if waitForPIDExit(pid, 2*time.Second) {
|
||||
return startDaemon(ctx, layout)
|
||||
}
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
}
|
||||
if !waitForPIDExit(pid, 2*time.Second) {
|
||||
return fmt.Errorf("timed out restarting stale daemon pid %d", pid)
|
||||
}
|
||||
return startDaemon(ctx, layout)
|
||||
}
|
||||
|
||||
func waitForPIDExit(pid int, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !pidRunning(pid) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return !pidRunning(pid)
|
||||
}
|
||||
|
||||
func pidRunning(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func startDaemon(ctx context.Context, layout paths.Layout) error {
|
||||
if err := paths.Ensure(layout); err != nil {
|
||||
return err
|
||||
}
|
||||
logFile, err := os.OpenFile(layout.DaemonLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
daemonBin, err := paths.BangerdPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, daemonBin)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rpc.WaitForSocket(layout.SocketPath, 5*time.Second); err != nil {
|
||||
return fmt.Errorf("daemon failed to start; inspect %s: %w", layout.DaemonLog, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func vmSetParamsFromFlags(idOrName string, vcpu, memory int, diskSize string, nat, noNat bool) (api.VMSetParams, error) {
|
||||
if nat && noNat {
|
||||
return api.VMSetParams{}, errors.New("use only one of --nat or --no-nat")
|
||||
}
|
||||
params := api.VMSetParams{IDOrName: idOrName, WorkDiskSize: diskSize}
|
||||
if vcpu >= 0 {
|
||||
params.VCPUCount = &vcpu
|
||||
}
|
||||
if memory >= 0 {
|
||||
params.MemoryMiB = &memory
|
||||
}
|
||||
if nat || noNat {
|
||||
value := nat && !noNat
|
||||
params.NATEnabled = &value
|
||||
}
|
||||
if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil {
|
||||
return api.VMSetParams{}, errors.New("no VM settings changed")
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) {
|
||||
if guestIP == "" {
|
||||
return nil, errors.New("vm has no guest IP")
|
||||
}
|
||||
args := []string{}
|
||||
if cfg.RepoRoot != "" {
|
||||
args = append(args, "-i", filepath.Join(cfg.RepoRoot, "id_ed25519"))
|
||||
}
|
||||
args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "root@"+guestIP)
|
||||
args = append(args, extra...)
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func printJSON(out anyWriter, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(out, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func printVMSummary(out anyWriter, vm model.VMRecord) error {
|
||||
_, err := fmt.Fprintf(
|
||||
out,
|
||||
"%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
shortID(vm.ID),
|
||||
vm.Name,
|
||||
vm.State,
|
||||
vm.Runtime.GuestIP,
|
||||
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
|
||||
vm.Runtime.DNSName,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func printImageSummary(out anyWriter, image model.Image) error {
|
||||
_, err := fmt.Fprintf(out, "%s\t%s\t%t\t%s\n", shortID(image.ID), image.Name, image.Managed, image.RootfsPath)
|
||||
return err
|
||||
}
|
||||
|
||||
type anyWriter interface {
|
||||
Write(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 12 {
|
||||
return id
|
||||
}
|
||||
return id[:12]
|
||||
}
|
||||
|
||||
func relativeTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
delta := time.Since(t)
|
||||
switch {
|
||||
case delta < 30*time.Second:
|
||||
return "moments ago"
|
||||
case delta < time.Minute:
|
||||
return fmt.Sprintf("%d seconds ago", int(delta.Seconds()))
|
||||
case delta < 2*time.Minute:
|
||||
return "1 minute ago"
|
||||
case delta < time.Hour:
|
||||
return fmt.Sprintf("%d minutes ago", int(delta.Minutes()))
|
||||
case delta < 2*time.Hour:
|
||||
return "1 hour ago"
|
||||
case delta < 24*time.Hour:
|
||||
return fmt.Sprintf("%d hours ago", int(delta.Hours()))
|
||||
case delta < 48*time.Hour:
|
||||
return "1 day ago"
|
||||
case delta < 7*24*time.Hour:
|
||||
return fmt.Sprintf("%d days ago", int(delta.Hours()/24))
|
||||
case delta < 14*24*time.Hour:
|
||||
return "1 week ago"
|
||||
default:
|
||||
return fmt.Sprintf("%d weeks ago", int(delta.Hours()/(24*7)))
|
||||
}
|
||||
}
|
||||
27
internal/cli/bangerd.go
Normal file
27
internal/cli/bangerd.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"banger/internal/daemon"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewBangerdCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "bangerd",
|
||||
Short: "Run the banger daemon",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Args: noArgsUsage("usage: bangerd"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
d, err := daemon.Open(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Serve(cmd.Context())
|
||||
},
|
||||
}
|
||||
cmd.CompletionOptions.DisableDefaultCmd = true
|
||||
return cmd
|
||||
}
|
||||
129
internal/cli/cli_test.go
Normal file
129
internal/cli/cli_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"banger/internal/model"
|
||||
)
|
||||
|
||||
func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
|
||||
cmd := NewBangerCommand()
|
||||
names := []string{}
|
||||
for _, sub := range cmd.Commands() {
|
||||
names = append(names, sub.Name())
|
||||
}
|
||||
want := []string{"daemon", "image", "tui", "vm"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("subcommands = %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMCreateFlagsExist(t *testing.T) {
|
||||
root := NewBangerCommand()
|
||||
vm, _, err := root.Find([]string{"vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("find vm: %v", err)
|
||||
}
|
||||
create, _, err := vm.Find([]string{"create"})
|
||||
if err != nil {
|
||||
t.Fatalf("find create: %v", err)
|
||||
}
|
||||
for _, flagName := range []string{"name", "image", "vcpu", "memory", "system-overlay-size", "disk-size", "nat", "no-start"} {
|
||||
if create.Flags().Lookup(flagName) == nil {
|
||||
t.Fatalf("missing flag %q", flagName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSetParamsFromFlags(t *testing.T) {
|
||||
params, err := vmSetParamsFromFlags("devbox", 4, 2048, "16G", true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("vmSetParamsFromFlags: %v", err)
|
||||
}
|
||||
if params.IDOrName != "devbox" || params.VCPUCount == nil || *params.VCPUCount != 4 {
|
||||
t.Fatalf("unexpected params: %+v", params)
|
||||
}
|
||||
if params.MemoryMiB == nil || *params.MemoryMiB != 2048 {
|
||||
t.Fatalf("unexpected memory: %+v", params)
|
||||
}
|
||||
if params.WorkDiskSize != "16G" {
|
||||
t.Fatalf("unexpected disk size: %+v", params)
|
||||
}
|
||||
if params.NATEnabled == nil || !*params.NATEnabled {
|
||||
t.Fatalf("unexpected nat value: %+v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMSetParamsFromFlagsConflict(t *testing.T) {
|
||||
if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil {
|
||||
t.Fatal("expected nat conflict error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommandArgs(t *testing.T) {
|
||||
args, err := sshCommandArgs(model.DaemonConfig{RepoRoot: "/repo"}, "172.16.0.2", []string{"--", "uname", "-a"})
|
||||
if err != nil {
|
||||
t.Fatalf("sshCommandArgs: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"-i", "/repo/id_ed25519",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"root@172.16.0.2",
|
||||
"--", "uname", "-a",
|
||||
}
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("args = %v, want %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBangerdCommandRejectsArgs(t *testing.T) {
|
||||
cmd := NewBangerdCommand()
|
||||
cmd.SetArgs([]string{"extra"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected extra args to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonOutdated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
current := filepath.Join(dir, "bangerd-current")
|
||||
same := filepath.Join(dir, "bangerd-same")
|
||||
stale := filepath.Join(dir, "bangerd-stale")
|
||||
if err := os.WriteFile(current, []byte("current"), 0o755); err != nil {
|
||||
t.Fatalf("write current: %v", err)
|
||||
}
|
||||
if err := os.Link(current, same); err != nil {
|
||||
t.Fatalf("hard link: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(stale, []byte("stale"), 0o755); err != nil {
|
||||
t.Fatalf("write stale: %v", err)
|
||||
}
|
||||
|
||||
origBangerdPath := bangerdPathFunc
|
||||
origDaemonExePath := daemonExePath
|
||||
t.Cleanup(func() {
|
||||
bangerdPathFunc = origBangerdPath
|
||||
daemonExePath = origDaemonExePath
|
||||
})
|
||||
|
||||
bangerdPathFunc = func() (string, error) {
|
||||
return current, nil
|
||||
}
|
||||
daemonExePath = func(pid int) string {
|
||||
if pid == 1 {
|
||||
return same
|
||||
}
|
||||
return stale
|
||||
}
|
||||
|
||||
if daemonOutdated(1) {
|
||||
t.Fatal("expected matching daemon executable to be current")
|
||||
}
|
||||
if !daemonOutdated(2) {
|
||||
t.Fatal("expected replaced daemon executable to be outdated")
|
||||
}
|
||||
}
|
||||
1385
internal/cli/tui.go
Normal file
1385
internal/cli/tui.go
Normal file
File diff suppressed because it is too large
Load diff
89
internal/cli/tui_test.go
Normal file
89
internal/cli/tui_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"banger/internal/model"
|
||||
)
|
||||
|
||||
func TestCreateVMFormSubmit(t *testing.T) {
|
||||
form := newCreateVMForm([]model.Image{{Name: "default"}}, model.DaemonConfig{DefaultImageName: "default"})
|
||||
form.fields[0].input.SetValue("devbox")
|
||||
form.fields[2].input.SetValue("4")
|
||||
form.fields[3].input.SetValue("2048")
|
||||
form.fields[4].input.SetValue("12G")
|
||||
form.fields[5].input.SetValue("24G")
|
||||
form.fields[6].index = 1
|
||||
|
||||
action, err := form.submit()
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if action.kind != actionCreate {
|
||||
t.Fatalf("kind = %s, want %s", action.kind, actionCreate)
|
||||
}
|
||||
if action.create.Name != "devbox" || action.create.ImageName != "default" {
|
||||
t.Fatalf("unexpected create params: %+v", action.create)
|
||||
}
|
||||
if action.create.VCPUCount != 4 || action.create.MemoryMiB != 2048 {
|
||||
t.Fatalf("unexpected cpu/memory: %+v", action.create)
|
||||
}
|
||||
if action.create.SystemOverlaySize != "12G" || action.create.WorkDiskSize != "24G" {
|
||||
t.Fatalf("unexpected disk sizes: %+v", action.create)
|
||||
}
|
||||
if !action.create.NATEnabled {
|
||||
t.Fatalf("expected NAT enabled: %+v", action.create)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditVMFormSubmit(t *testing.T) {
|
||||
form := newEditVMForm(model.VMRecord{
|
||||
ID: "vm-1",
|
||||
Spec: model.VMSpec{
|
||||
VCPUCount: 2,
|
||||
MemoryMiB: 1024,
|
||||
WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024,
|
||||
NATEnabled: false,
|
||||
},
|
||||
})
|
||||
form.fields[0].input.SetValue("6")
|
||||
form.fields[1].input.SetValue("4096")
|
||||
form.fields[2].input.SetValue("32G")
|
||||
form.fields[3].index = 1
|
||||
|
||||
action, err := form.submit()
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if action.kind != actionEdit {
|
||||
t.Fatalf("kind = %s, want %s", action.kind, actionEdit)
|
||||
}
|
||||
if action.set.IDOrName != "vm-1" {
|
||||
t.Fatalf("unexpected vm id: %+v", action.set)
|
||||
}
|
||||
if action.set.VCPUCount == nil || *action.set.VCPUCount != 6 {
|
||||
t.Fatalf("unexpected vcpu: %+v", action.set)
|
||||
}
|
||||
if action.set.MemoryMiB == nil || *action.set.MemoryMiB != 4096 {
|
||||
t.Fatalf("unexpected memory: %+v", action.set)
|
||||
}
|
||||
if action.set.WorkDiskSize != "32G" {
|
||||
t.Fatalf("unexpected disk size: %+v", action.set)
|
||||
}
|
||||
if action.set.NATEnabled == nil || !*action.set.NATEnabled {
|
||||
t.Fatalf("expected nat enabled: %+v", action.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSelectedID(t *testing.T) {
|
||||
vms := []model.VMRecord{{ID: "one"}, {ID: "two"}}
|
||||
if got := resolveSelectedID("two", vms); got != "two" {
|
||||
t.Fatalf("resolveSelectedID existing = %q, want %q", got, "two")
|
||||
}
|
||||
if got := resolveSelectedID("missing", vms); got != "one" {
|
||||
t.Fatalf("resolveSelectedID fallback = %q, want %q", got, "one")
|
||||
}
|
||||
if got := resolveSelectedID("anything", nil); got != "" {
|
||||
t.Fatalf("resolveSelectedID empty = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
125
internal/config/config.go
Normal file
125
internal/config/config.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
toml "github.com/pelletier/go-toml"
|
||||
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
)
|
||||
|
||||
type fileConfig struct {
|
||||
RepoRoot string `toml:"repo_root"`
|
||||
DefaultImageName string `toml:"default_image_name"`
|
||||
DefaultBaseRootfs string `toml:"default_base_rootfs"`
|
||||
DefaultKernel string `toml:"default_kernel"`
|
||||
DefaultInitrd string `toml:"default_initrd"`
|
||||
DefaultModulesDir string `toml:"default_modules_dir"`
|
||||
DefaultPackages string `toml:"default_packages_file"`
|
||||
AutoStopStaleAfter string `toml:"auto_stop_stale_after"`
|
||||
StatsPollInterval string `toml:"stats_poll_interval"`
|
||||
MetricsPoll string `toml:"metrics_poll_interval"`
|
||||
BridgeName string `toml:"bridge_name"`
|
||||
BridgeIP string `toml:"bridge_ip"`
|
||||
CIDR string `toml:"cidr"`
|
||||
DefaultDNS string `toml:"default_dns"`
|
||||
}
|
||||
|
||||
func Load(layout paths.Layout) (model.DaemonConfig, error) {
|
||||
cfg := model.DaemonConfig{
|
||||
RepoRoot: paths.DetectRepoRoot(),
|
||||
AutoStopStaleAfter: 0,
|
||||
StatsPollInterval: model.DefaultStatsPollInterval,
|
||||
MetricsPollInterval: model.DefaultMetricsPollInterval,
|
||||
BridgeName: model.DefaultBridgeName,
|
||||
BridgeIP: model.DefaultBridgeIP,
|
||||
CIDR: model.DefaultCIDR,
|
||||
DefaultDNS: model.DefaultDNS,
|
||||
DefaultImageName: "default",
|
||||
}
|
||||
if cfg.RepoRoot != "" {
|
||||
cfg.DefaultBaseRootfs = filepath.Join(cfg.RepoRoot, "rootfs.ext4")
|
||||
cfg.DefaultKernel = filepath.Join(cfg.RepoRoot, "wtf/root/boot/vmlinux-6.8.0-94-generic")
|
||||
cfg.DefaultInitrd = filepath.Join(cfg.RepoRoot, "wtf/root/boot/initrd.img-6.8.0-94-generic")
|
||||
cfg.DefaultModulesDir = filepath.Join(cfg.RepoRoot, "wtf/root/lib/modules/6.8.0-94-generic")
|
||||
cfg.DefaultPackagesFile = filepath.Join(cfg.RepoRoot, "packages.apt")
|
||||
}
|
||||
|
||||
path := filepath.Join(layout.ConfigDir, "config.toml")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return cfg, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
var file fileConfig
|
||||
if err := toml.Unmarshal(data, &file); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if file.RepoRoot != "" {
|
||||
cfg.RepoRoot = file.RepoRoot
|
||||
}
|
||||
if file.DefaultImageName != "" {
|
||||
cfg.DefaultImageName = file.DefaultImageName
|
||||
}
|
||||
if file.DefaultBaseRootfs != "" {
|
||||
cfg.DefaultBaseRootfs = file.DefaultBaseRootfs
|
||||
}
|
||||
if file.DefaultKernel != "" {
|
||||
cfg.DefaultKernel = file.DefaultKernel
|
||||
}
|
||||
if file.DefaultInitrd != "" {
|
||||
cfg.DefaultInitrd = file.DefaultInitrd
|
||||
}
|
||||
if file.DefaultModulesDir != "" {
|
||||
cfg.DefaultModulesDir = file.DefaultModulesDir
|
||||
}
|
||||
if file.DefaultPackages != "" {
|
||||
cfg.DefaultPackagesFile = file.DefaultPackages
|
||||
}
|
||||
if file.BridgeName != "" {
|
||||
cfg.BridgeName = file.BridgeName
|
||||
}
|
||||
if file.BridgeIP != "" {
|
||||
cfg.BridgeIP = file.BridgeIP
|
||||
}
|
||||
if file.CIDR != "" {
|
||||
cfg.CIDR = file.CIDR
|
||||
}
|
||||
if file.DefaultDNS != "" {
|
||||
cfg.DefaultDNS = file.DefaultDNS
|
||||
}
|
||||
if file.AutoStopStaleAfter != "" {
|
||||
duration, err := time.ParseDuration(file.AutoStopStaleAfter)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.AutoStopStaleAfter = duration
|
||||
}
|
||||
if file.StatsPollInterval != "" {
|
||||
duration, err := time.ParseDuration(file.StatsPollInterval)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.StatsPollInterval = duration
|
||||
}
|
||||
if file.MetricsPoll != "" {
|
||||
duration, err := time.ParseDuration(file.MetricsPoll)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.MetricsPollInterval = duration
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
420
internal/daemon/daemon.go
Normal file
420
internal/daemon/daemon.go
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/config"
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/rpc"
|
||||
"banger/internal/store"
|
||||
"banger/internal/system"
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
layout paths.Layout
|
||||
config model.DaemonConfig
|
||||
store *store.Store
|
||||
runner system.Runner
|
||||
mu sync.Mutex
|
||||
closing chan struct{}
|
||||
once sync.Once
|
||||
pid int
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
func Open(ctx context.Context) (*Daemon, error) {
|
||||
layout, err := paths.Resolve()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := paths.Ensure(layout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := config.Load(layout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := store.Open(layout.DBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &Daemon{
|
||||
layout: layout,
|
||||
config: cfg,
|
||||
store: db,
|
||||
runner: system.NewRunner(),
|
||||
closing: make(chan struct{}),
|
||||
pid: os.Getpid(),
|
||||
}
|
||||
if err := d.ensureDefaultImage(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.reconcile(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) Close() error {
|
||||
var err error
|
||||
d.once.Do(func() {
|
||||
close(d.closing)
|
||||
if d.listener != nil {
|
||||
_ = d.listener.Close()
|
||||
}
|
||||
err = d.store.Close()
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) Serve(ctx context.Context) error {
|
||||
_ = os.Remove(d.layout.SocketPath)
|
||||
listener, err := net.Listen("unix", d.layout.SocketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.listener = listener
|
||||
defer listener.Close()
|
||||
defer os.Remove(d.layout.SocketPath)
|
||||
if err := os.Chmod(d.layout.SocketPath, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go d.backgroundLoop()
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-d.closing:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Temporary() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
go d.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
var req rpc.Request
|
||||
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&req); err != nil {
|
||||
_ = json.NewEncoder(conn).Encode(rpc.NewError("bad_request", err.Error()))
|
||||
return
|
||||
}
|
||||
resp := d.dispatch(req)
|
||||
_ = json.NewEncoder(conn).Encode(resp)
|
||||
}
|
||||
|
||||
func (d *Daemon) dispatch(req rpc.Request) rpc.Response {
|
||||
if req.Version != rpc.Version {
|
||||
return rpc.NewError("bad_version", fmt.Sprintf("unsupported version %d", req.Version))
|
||||
}
|
||||
ctx := context.Background()
|
||||
switch req.Method {
|
||||
case "ping":
|
||||
result, _ := rpc.NewResult(api.PingResult{Status: "ok", PID: d.pid})
|
||||
return result
|
||||
case "shutdown":
|
||||
go d.Close()
|
||||
result, _ := rpc.NewResult(api.ShutdownResult{Status: "stopping"})
|
||||
return result
|
||||
case "vm.create":
|
||||
params, err := rpc.DecodeParams[api.VMCreateParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.CreateVM(ctx, params)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.list":
|
||||
vms, err := d.store.ListVMs(ctx)
|
||||
return marshalResultOrError(api.VMListResult{VMs: vms}, err)
|
||||
case "vm.show":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.FindVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.start":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.StartVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.stop":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.StopVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.restart":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.RestartVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.delete":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.DeleteVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.set":
|
||||
params, err := rpc.DecodeParams[api.VMSetParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.SetVM(ctx, params)
|
||||
return marshalResultOrError(api.VMShowResult{VM: vm}, err)
|
||||
case "vm.stats":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, stats, err := d.GetVMStats(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.VMStatsResult{VM: vm, Stats: stats}, err)
|
||||
case "vm.logs":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.FindVM(ctx, params.IDOrName)
|
||||
if err != nil {
|
||||
return rpc.NewError("not_found", err.Error())
|
||||
}
|
||||
return marshalResultOrError(api.VMLogsResult{LogPath: vm.Runtime.LogPath}, nil)
|
||||
case "vm.ssh":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
vm, err := d.TouchVM(ctx, params.IDOrName)
|
||||
if err != nil {
|
||||
return rpc.NewError("not_found", err.Error())
|
||||
}
|
||||
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
return rpc.NewError("not_running", fmt.Sprintf("vm %s is not running", vm.Name))
|
||||
}
|
||||
return marshalResultOrError(api.VMSSHResult{Name: vm.Name, GuestIP: vm.Runtime.GuestIP}, nil)
|
||||
case "image.list":
|
||||
images, err := d.store.ListImages(ctx)
|
||||
return marshalResultOrError(api.ImageListResult{Images: images}, err)
|
||||
case "image.show":
|
||||
params, err := rpc.DecodeParams[api.ImageRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
image, err := d.FindImage(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
case "image.build":
|
||||
params, err := rpc.DecodeParams[api.ImageBuildParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
image, err := d.BuildImage(ctx, params)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
case "image.delete":
|
||||
params, err := rpc.DecodeParams[api.ImageRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
image, err := d.DeleteImage(ctx, params.IDOrName)
|
||||
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
|
||||
default:
|
||||
return rpc.NewError("unknown_method", req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) backgroundLoop() {
|
||||
statsTicker := time.NewTicker(d.config.StatsPollInterval)
|
||||
staleTicker := time.NewTicker(model.DefaultStaleSweepInterval)
|
||||
defer statsTicker.Stop()
|
||||
defer staleTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-d.closing:
|
||||
return
|
||||
case <-statsTicker.C:
|
||||
_ = d.pollStats(context.Background())
|
||||
case <-staleTicker.C:
|
||||
_ = d.stopStaleVMs(context.Background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureDefaultImage(ctx context.Context) error {
|
||||
if d.config.DefaultImageName == "" || d.config.RepoRoot == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := d.store.GetImageByName(ctx, d.config.DefaultImageName); err == nil {
|
||||
return nil
|
||||
}
|
||||
rootfs := filepath.Join(d.config.RepoRoot, "rootfs-docker.ext4")
|
||||
kernel := d.config.DefaultKernel
|
||||
initrd := d.config.DefaultInitrd
|
||||
if !exists(rootfs) || !exists(kernel) {
|
||||
return nil
|
||||
}
|
||||
id, err := model.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := model.Now()
|
||||
image := model.Image{
|
||||
ID: id,
|
||||
Name: d.config.DefaultImageName,
|
||||
Managed: false,
|
||||
RootfsPath: rootfs,
|
||||
KernelPath: kernel,
|
||||
InitrdPath: initrd,
|
||||
ModulesDir: d.config.DefaultModulesDir,
|
||||
PackagesPath: d.config.DefaultPackagesFile,
|
||||
Docker: strings.Contains(filepath.Base(rootfs), "docker"),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
return d.store.UpsertImage(ctx, image)
|
||||
}
|
||||
|
||||
func (d *Daemon) reconcile(ctx context.Context) error {
|
||||
vms, err := d.store.ListVMs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, vm := range vms {
|
||||
if vm.State != model.VMStateRunning {
|
||||
continue
|
||||
}
|
||||
if system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
continue
|
||||
}
|
||||
_ = d.cleanupRuntime(ctx, vm, true)
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
vm.Runtime.PID = 0
|
||||
vm.Runtime.TapDevice = ""
|
||||
vm.Runtime.APISockPath = ""
|
||||
vm.Runtime.BaseLoop = ""
|
||||
vm.Runtime.COWLoop = ""
|
||||
vm.Runtime.DMName = ""
|
||||
vm.Runtime.DMDev = ""
|
||||
vm.UpdatedAt = model.Now()
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) FindVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
if idOrName == "" {
|
||||
return model.VMRecord{}, errors.New("vm id or name is required")
|
||||
}
|
||||
if vm, err := d.store.GetVM(ctx, idOrName); err == nil {
|
||||
return vm, nil
|
||||
}
|
||||
vms, err := d.store.ListVMs(ctx)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
matchCount := 0
|
||||
var match model.VMRecord
|
||||
for _, vm := range vms {
|
||||
if strings.HasPrefix(vm.ID, idOrName) || strings.HasPrefix(vm.Name, idOrName) {
|
||||
match = vm
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
if matchCount == 1 {
|
||||
return match, nil
|
||||
}
|
||||
if matchCount > 1 {
|
||||
return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", idOrName)
|
||||
}
|
||||
return model.VMRecord{}, fmt.Errorf("vm %q not found", idOrName)
|
||||
}
|
||||
|
||||
func (d *Daemon) FindImage(ctx context.Context, idOrName string) (model.Image, error) {
|
||||
if idOrName == "" {
|
||||
return model.Image{}, errors.New("image id or name is required")
|
||||
}
|
||||
if image, err := d.store.GetImageByName(ctx, idOrName); err == nil {
|
||||
return image, nil
|
||||
}
|
||||
if image, err := d.store.GetImageByID(ctx, idOrName); err == nil {
|
||||
return image, nil
|
||||
}
|
||||
images, err := d.store.ListImages(ctx)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
matchCount := 0
|
||||
var match model.Image
|
||||
for _, image := range images {
|
||||
if strings.HasPrefix(image.ID, idOrName) || strings.HasPrefix(image.Name, idOrName) {
|
||||
match = image
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
if matchCount == 1 {
|
||||
return match, nil
|
||||
}
|
||||
if matchCount > 1 {
|
||||
return model.Image{}, fmt.Errorf("multiple images match %q", idOrName)
|
||||
}
|
||||
return model.Image{}, fmt.Errorf("image %q not found", idOrName)
|
||||
}
|
||||
|
||||
func (d *Daemon) TouchVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vm, err := d.FindVM(ctx, idOrName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
system.TouchNow(&vm)
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
func marshalResultOrError(v any, err error) rpc.Response {
|
||||
if err != nil {
|
||||
return rpc.NewError("operation_failed", err.Error())
|
||||
}
|
||||
resp, marshalErr := rpc.NewResult(v)
|
||||
if marshalErr != nil {
|
||||
return rpc.NewError("marshal_failed", marshalErr.Error())
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
131
internal/daemon/images.go
Normal file
131
internal/daemon/images.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/model"
|
||||
)
|
||||
|
||||
func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (model.Image, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
name := params.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("image-%d", model.Now().Unix())
|
||||
}
|
||||
if _, err := d.FindImage(ctx, name); err == nil {
|
||||
return model.Image{}, fmt.Errorf("image name already exists: %s", name)
|
||||
}
|
||||
if d.config.RepoRoot == "" {
|
||||
return model.Image{}, fmt.Errorf("repo root not found; set repo_root in config.toml")
|
||||
}
|
||||
baseRootfs := params.BaseRootfs
|
||||
if baseRootfs == "" {
|
||||
baseRootfs = d.config.DefaultBaseRootfs
|
||||
}
|
||||
if baseRootfs == "" {
|
||||
return model.Image{}, fmt.Errorf("base rootfs is required")
|
||||
}
|
||||
id, err := model.NewID()
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
now := model.Now()
|
||||
artifactDir := filepath.Join(d.layout.ImagesDir, id)
|
||||
if err := os.MkdirAll(artifactDir, 0o755); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
rootfsPath := filepath.Join(artifactDir, "rootfs.ext4")
|
||||
script := filepath.Join(d.config.RepoRoot, "customize.sh")
|
||||
if _, err := os.Stat(script); err != nil {
|
||||
return model.Image{}, fmt.Errorf("customize.sh not found at %s", script)
|
||||
}
|
||||
args := []string{script, baseRootfs, "--out", rootfsPath}
|
||||
if params.Size != "" {
|
||||
args = append(args, "--size", params.Size)
|
||||
}
|
||||
kernelPath := params.KernelPath
|
||||
if kernelPath == "" {
|
||||
kernelPath = d.config.DefaultKernel
|
||||
}
|
||||
if kernelPath != "" {
|
||||
args = append(args, "--kernel", kernelPath)
|
||||
}
|
||||
initrdPath := params.InitrdPath
|
||||
if initrdPath == "" {
|
||||
initrdPath = d.config.DefaultInitrd
|
||||
}
|
||||
if initrdPath != "" {
|
||||
args = append(args, "--initrd", initrdPath)
|
||||
}
|
||||
modulesDir := params.ModulesDir
|
||||
if modulesDir == "" {
|
||||
modulesDir = d.config.DefaultModulesDir
|
||||
}
|
||||
if modulesDir != "" {
|
||||
args = append(args, "--modules", modulesDir)
|
||||
}
|
||||
if params.Docker {
|
||||
args = append(args, "--docker")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "bash", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Dir = d.config.RepoRoot
|
||||
if err := cmd.Run(); err != nil {
|
||||
_ = os.RemoveAll(artifactDir)
|
||||
return model.Image{}, err
|
||||
}
|
||||
image := model.Image{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Managed: true,
|
||||
ArtifactDir: artifactDir,
|
||||
RootfsPath: rootfsPath,
|
||||
KernelPath: kernelPath,
|
||||
InitrdPath: initrdPath,
|
||||
ModulesDir: modulesDir,
|
||||
PackagesPath: d.config.DefaultPackagesFile,
|
||||
BuildSize: params.Size,
|
||||
Docker: params.Docker,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := d.store.UpsertImage(ctx, image); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
image, err := d.FindImage(ctx, idOrName)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
vms, err := d.store.FindVMsUsingImage(ctx, image.ID)
|
||||
if err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
if len(vms) > 0 {
|
||||
return model.Image{}, fmt.Errorf("image %s is still referenced by %d VM(s)", image.Name, len(vms))
|
||||
}
|
||||
if err := d.store.DeleteImage(ctx, image.ID); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
if image.Managed && image.ArtifactDir != "" {
|
||||
if err := os.RemoveAll(image.ArtifactDir); err != nil {
|
||||
return model.Image{}, err
|
||||
}
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
845
internal/daemon/vm.go
Normal file
845
internal/daemon/vm.go
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/firecracker"
|
||||
"banger/internal/model"
|
||||
"banger/internal/system"
|
||||
)
|
||||
|
||||
func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (model.VMRecord, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
imageName := params.ImageName
|
||||
if imageName == "" {
|
||||
imageName = d.config.DefaultImageName
|
||||
}
|
||||
image, err := d.FindImage(ctx, imageName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
name := strings.TrimSpace(params.Name)
|
||||
if name == "" {
|
||||
name, err = d.generateName(ctx)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
}
|
||||
if _, err := d.FindVM(ctx, name); err == nil {
|
||||
return model.VMRecord{}, fmt.Errorf("vm name already exists: %s", name)
|
||||
}
|
||||
id, err := model.NewID()
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
guestIP, err := d.store.NextGuestIP(ctx, bridgePrefix(d.config.BridgeIP))
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
vmDir := filepath.Join(d.layout.VMsDir, id)
|
||||
if err := os.MkdirAll(vmDir, 0o755); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
systemOverlaySize := int64(model.DefaultSystemOverlaySize)
|
||||
if params.SystemOverlaySize != "" {
|
||||
systemOverlaySize, err = model.ParseSize(params.SystemOverlaySize)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
}
|
||||
workDiskSize := int64(model.DefaultWorkDiskSize)
|
||||
if params.WorkDiskSize != "" {
|
||||
workDiskSize, err = model.ParseSize(params.WorkDiskSize)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
}
|
||||
now := model.Now()
|
||||
spec := model.VMSpec{
|
||||
VCPUCount: defaultInt(params.VCPUCount, model.DefaultVCPUCount),
|
||||
MemoryMiB: defaultInt(params.MemoryMiB, model.DefaultMemoryMiB),
|
||||
SystemOverlaySizeByte: systemOverlaySize,
|
||||
WorkDiskSizeBytes: workDiskSize,
|
||||
NATEnabled: params.NATEnabled,
|
||||
}
|
||||
vm := model.VMRecord{
|
||||
ID: id,
|
||||
Name: name,
|
||||
ImageID: image.ID,
|
||||
State: model.VMStateCreated,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
LastTouchedAt: now,
|
||||
Spec: spec,
|
||||
Runtime: model.VMRuntime{
|
||||
State: model.VMStateCreated,
|
||||
GuestIP: guestIP,
|
||||
DNSName: name + ".vm",
|
||||
VMDir: vmDir,
|
||||
SystemOverlay: filepath.Join(vmDir, "system.cow"),
|
||||
WorkDiskPath: filepath.Join(vmDir, "root.ext4"),
|
||||
LogPath: filepath.Join(vmDir, "firecracker.log"),
|
||||
MetricsPath: filepath.Join(vmDir, "metrics.json"),
|
||||
},
|
||||
}
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if params.NoStart {
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
return d.startVMLocked(ctx, vm, image)
|
||||
}
|
||||
|
||||
func (d *Daemon) StartVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vm, err := d.FindVM(ctx, idOrName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
image, err := d.store.GetImageByID(ctx, vm.ImageID)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
return vm, nil
|
||||
}
|
||||
return d.startVMLocked(ctx, vm, image)
|
||||
}
|
||||
|
||||
func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image model.Image) (model.VMRecord, error) {
|
||||
if err := d.requireStartPrereqs(ctx); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if err := os.MkdirAll(vm.Runtime.VMDir, 0o755); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if err := d.cleanupRuntime(ctx, vm, true); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
clearRuntimeHandles(&vm)
|
||||
if err := d.ensureBridge(ctx); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if err := d.ensureSocketDir(); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
|
||||
shortID := system.ShortID(vm.ID)
|
||||
apiSock := filepath.Join(d.layout.RuntimeDir, "fc-"+shortID+".sock")
|
||||
tap := "tap-fc-" + shortID
|
||||
dmName := "fc-rootfs-" + shortID
|
||||
if err := os.RemoveAll(apiSock); err != nil && !os.IsNotExist(err) {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
|
||||
if err := d.ensureSystemOverlay(ctx, &vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
|
||||
baseLoop, cowLoop, dmDev, err := d.createDMSnapshot(ctx, image.RootfsPath, vm.Runtime.SystemOverlay, dmName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
vm.Runtime.BaseLoop = baseLoop
|
||||
vm.Runtime.COWLoop = cowLoop
|
||||
vm.Runtime.DMName = dmName
|
||||
vm.Runtime.DMDev = dmDev
|
||||
vm.Runtime.APISockPath = apiSock
|
||||
vm.Runtime.TapDevice = tap
|
||||
vm.Runtime.State = model.VMStateRunning
|
||||
vm.State = model.VMStateRunning
|
||||
vm.Runtime.LastError = ""
|
||||
|
||||
cleanupOnErr := func(err error) (model.VMRecord, error) {
|
||||
vm.State = model.VMStateError
|
||||
vm.Runtime.State = model.VMStateError
|
||||
vm.Runtime.LastError = err.Error()
|
||||
_ = d.cleanupRuntime(context.Background(), vm, true)
|
||||
clearRuntimeHandles(&vm)
|
||||
_ = d.store.UpsertVM(context.Background(), vm)
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
|
||||
if err := d.patchRootOverlay(ctx, vm, image); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if err := d.ensureWorkDisk(ctx, &vm); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if err := d.createTap(ctx, tap); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if err := os.WriteFile(vm.Runtime.MetricsPath, nil, 0o644); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
|
||||
fcPath, err := d.firecrackerBinary()
|
||||
if err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
pid, err := d.startFirecrackerProcess(ctx, fcPath, apiSock, vm.Runtime.LogPath)
|
||||
if err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
vm.Runtime.PID = pid
|
||||
|
||||
if err := d.waitForSocket(ctx, apiSock); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if actualPID, err := d.findFirecrackerPID(ctx, apiSock); err == nil && actualPID > 0 {
|
||||
vm.Runtime.PID = actualPID
|
||||
}
|
||||
|
||||
client := firecracker.New(apiSock)
|
||||
if err := client.Put(ctx, "/machine-config", map[string]any{
|
||||
"vcpu_count": vm.Spec.VCPUCount,
|
||||
"mem_size_mib": vm.Spec.MemoryMiB,
|
||||
"smt": false,
|
||||
}); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if err := client.Put(ctx, "/metrics", map[string]any{
|
||||
"metrics_path": vm.Runtime.MetricsPath,
|
||||
}); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
boot := map[string]any{
|
||||
"kernel_image_path": image.KernelPath,
|
||||
"boot_args": system.BuildBootArgs(vm.Name, vm.Runtime.GuestIP, d.config.BridgeIP, d.config.DefaultDNS),
|
||||
}
|
||||
if image.InitrdPath != "" {
|
||||
boot["initrd_path"] = image.InitrdPath
|
||||
}
|
||||
if err := client.Put(ctx, "/boot-source", boot); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if err := client.Put(ctx, "/drives/rootfs", map[string]any{
|
||||
"drive_id": "rootfs",
|
||||
"path_on_host": vm.Runtime.DMDev,
|
||||
"is_root_device": true,
|
||||
"is_read_only": false,
|
||||
}); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if err := client.Put(ctx, "/drives/work", map[string]any{
|
||||
"drive_id": "work",
|
||||
"path_on_host": vm.Runtime.WorkDiskPath,
|
||||
"is_root_device": false,
|
||||
"is_read_only": false,
|
||||
}); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if err := client.Put(ctx, "/network-interfaces/eth0", map[string]any{
|
||||
"iface_id": "eth0",
|
||||
"host_dev_name": tap,
|
||||
}); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if err := client.Put(ctx, "/actions", map[string]any{"action_type": "InstanceStart"}); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
fcConfig, _ := client.GetConfig(ctx)
|
||||
vm.Runtime.FirecrackerState = fcConfig
|
||||
if err := d.setDNS(ctx, vm.Name, vm.Runtime.GuestIP); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
if vm.Spec.NATEnabled {
|
||||
if err := d.ensureNAT(ctx, vm, true); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
}
|
||||
system.TouchNow(&vm)
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) StopVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vm, err := d.FindVM(ctx, idOrName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
if err := d.cleanupRuntime(ctx, vm, true); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
clearRuntimeHandles(&vm)
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
if err := d.sendCtrlAltDel(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if err := d.waitForExit(ctx, vm.Runtime.PID, vm.Runtime.APISockPath, 30*time.Second); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if err := d.cleanupRuntime(ctx, vm, true); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
clearRuntimeHandles(&vm)
|
||||
system.TouchNow(&vm)
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) RestartVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
vm, err := d.StopVM(ctx, idOrName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return d.StartVM(ctx, vm.ID)
|
||||
}
|
||||
|
||||
func (d *Daemon) DeleteVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vm, err := d.FindVM(ctx, idOrName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
_ = d.killVMProcess(ctx, vm.Runtime.PID)
|
||||
}
|
||||
if err := d.cleanupRuntime(ctx, vm, false); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if vm.Spec.NATEnabled {
|
||||
_ = d.ensureNAT(ctx, vm, false)
|
||||
}
|
||||
_ = d.removeDNS(ctx, vm.Runtime.DNSName)
|
||||
if err := d.store.DeleteVM(ctx, vm.ID); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if vm.Runtime.VMDir != "" {
|
||||
if err := os.RemoveAll(vm.Runtime.VMDir); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) SetVM(ctx context.Context, params api.VMSetParams) (model.VMRecord, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vm, err := d.FindVM(ctx, params.IDOrName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
running := vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath)
|
||||
if params.VCPUCount != nil {
|
||||
if running {
|
||||
return model.VMRecord{}, errors.New("vcpu changes require the VM to be stopped")
|
||||
}
|
||||
vm.Spec.VCPUCount = *params.VCPUCount
|
||||
}
|
||||
if params.MemoryMiB != nil {
|
||||
if running {
|
||||
return model.VMRecord{}, errors.New("memory changes require the VM to be stopped")
|
||||
}
|
||||
vm.Spec.MemoryMiB = *params.MemoryMiB
|
||||
}
|
||||
if params.WorkDiskSize != "" {
|
||||
size, err := model.ParseSize(params.WorkDiskSize)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
if running {
|
||||
return model.VMRecord{}, errors.New("disk changes require the VM to be stopped")
|
||||
}
|
||||
if size < vm.Spec.WorkDiskSizeBytes {
|
||||
return model.VMRecord{}, errors.New("disk size can only grow")
|
||||
}
|
||||
if size > vm.Spec.WorkDiskSizeBytes {
|
||||
if exists(vm.Runtime.WorkDiskPath) {
|
||||
if err := system.ResizeExt4Image(ctx, d.runner, vm.Runtime.WorkDiskPath, size); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
}
|
||||
vm.Spec.WorkDiskSizeBytes = size
|
||||
}
|
||||
}
|
||||
if params.NATEnabled != nil {
|
||||
vm.Spec.NATEnabled = *params.NATEnabled
|
||||
if running {
|
||||
if err := d.ensureNAT(ctx, vm, *params.NATEnabled); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
system.TouchNow(&vm)
|
||||
if err := d.store.UpsertVM(ctx, vm); err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) GetVMStats(ctx context.Context, idOrName string) (model.VMRecord, model.VMStats, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vm, err := d.FindVM(ctx, idOrName)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, model.VMStats{}, err
|
||||
}
|
||||
stats, err := d.collectStats(ctx, vm)
|
||||
if err == nil {
|
||||
vm.Stats = stats
|
||||
vm.UpdatedAt = model.Now()
|
||||
_ = d.store.UpsertVM(ctx, vm)
|
||||
}
|
||||
return vm, vm.Stats, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) pollStats(ctx context.Context) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vms, err := d.store.ListVMs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, vm := range vms {
|
||||
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
continue
|
||||
}
|
||||
stats, err := d.collectStats(ctx, vm)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vm.Stats = stats
|
||||
vm.UpdatedAt = model.Now()
|
||||
_ = d.store.UpsertVM(ctx, vm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) stopStaleVMs(ctx context.Context) error {
|
||||
if d.config.AutoStopStaleAfter <= 0 {
|
||||
return nil
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
vms, err := d.store.ListVMs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := model.Now()
|
||||
for _, vm := range vms {
|
||||
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
continue
|
||||
}
|
||||
if now.Sub(vm.LastTouchedAt) < d.config.AutoStopStaleAfter {
|
||||
continue
|
||||
}
|
||||
_ = d.sendCtrlAltDel(ctx, vm)
|
||||
_ = d.waitForExit(ctx, vm.Runtime.PID, vm.Runtime.APISockPath, 10*time.Second)
|
||||
_ = d.cleanupRuntime(ctx, vm, true)
|
||||
vm.State = model.VMStateStopped
|
||||
vm.Runtime.State = model.VMStateStopped
|
||||
clearRuntimeHandles(&vm)
|
||||
vm.UpdatedAt = model.Now()
|
||||
_ = d.store.UpsertVM(ctx, vm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) collectStats(ctx context.Context, vm model.VMRecord) (model.VMStats, error) {
|
||||
stats := model.VMStats{
|
||||
CollectedAt: model.Now(),
|
||||
SystemOverlayBytes: system.AllocatedBytes(vm.Runtime.SystemOverlay),
|
||||
WorkDiskBytes: system.AllocatedBytes(vm.Runtime.WorkDiskPath),
|
||||
MetricsRaw: system.ParseMetricsFile(vm.Runtime.MetricsPath),
|
||||
}
|
||||
if vm.Runtime.PID > 0 && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
ps, err := system.ReadProcessStats(ctx, vm.Runtime.PID)
|
||||
if err == nil {
|
||||
stats.CPUPercent = ps.CPUPercent
|
||||
stats.RSSBytes = ps.RSSBytes
|
||||
stats.VSZBytes = ps.VSZBytes
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureSystemOverlay(ctx context.Context, vm *model.VMRecord) error {
|
||||
if exists(vm.Runtime.SystemOverlay) {
|
||||
return nil
|
||||
}
|
||||
_, err := d.runner.Run(ctx, "truncate", "-s", strconv.FormatInt(vm.Spec.SystemOverlaySizeByte, 10), vm.Runtime.SystemOverlay)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) patchRootOverlay(ctx context.Context, vm model.VMRecord, image model.Image) error {
|
||||
resolv := []byte(fmt.Sprintf("nameserver %s\n", d.config.DefaultDNS))
|
||||
hostname := []byte(vm.Name + "\n")
|
||||
hosts := []byte(fmt.Sprintf("127.0.0.1 localhost\n127.0.1.1 %s\n", vm.Name))
|
||||
fstab, err := system.ReadDebugFSText(ctx, d.runner, vm.Runtime.DMDev, "/etc/fstab")
|
||||
if err != nil {
|
||||
fstab = ""
|
||||
}
|
||||
newFSTab := system.UpdateFSTab(fstab)
|
||||
for guestPath, data := range map[string][]byte{
|
||||
"/etc/resolv.conf": resolv,
|
||||
"/etc/hostname": hostname,
|
||||
"/etc/hosts": hosts,
|
||||
"/etc/fstab": []byte(newFSTab),
|
||||
} {
|
||||
if err := system.WriteExt4File(ctx, d.runner, vm.Runtime.DMDev, guestPath, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
if exists(vm.Runtime.WorkDiskPath) {
|
||||
return nil
|
||||
}
|
||||
if _, err := d.runner.Run(ctx, "truncate", "-s", strconv.FormatInt(vm.Spec.WorkDiskSizeBytes, 10), vm.Runtime.WorkDiskPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := d.runner.Run(ctx, "mkfs.ext4", "-F", vm.Runtime.WorkDiskPath); err != nil {
|
||||
return err
|
||||
}
|
||||
rootMount, cleanupRoot, err := system.MountTempDir(ctx, d.runner, vm.Runtime.DMDev, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupRoot()
|
||||
workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupWork()
|
||||
if err := system.CopyDirContents(ctx, d.runner, filepath.Join(rootMount, "root"), workMount, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) createDMSnapshot(ctx context.Context, rootfsPath, cowPath, dmName string) (baseLoop, cowLoop, dmDev string, err error) {
|
||||
baseBytes, err := d.runner.RunSudo(ctx, "losetup", "-f", "--show", "--read-only", rootfsPath)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
baseLoop = strings.TrimSpace(string(baseBytes))
|
||||
cowBytes, err := d.runner.RunSudo(ctx, "losetup", "-f", "--show", cowPath)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
cowLoop = strings.TrimSpace(string(cowBytes))
|
||||
sectorsBytes, err := d.runner.RunSudo(ctx, "blockdev", "--getsz", baseLoop)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
sectors := strings.TrimSpace(string(sectorsBytes))
|
||||
if _, err := d.runner.RunSudo(ctx, "dmsetup", "create", dmName, "--table", fmt.Sprintf("0 %s snapshot %s %s P 8", sectors, baseLoop, cowLoop)); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return baseLoop, cowLoop, "/dev/mapper/" + dmName, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureBridge(ctx context.Context) error {
|
||||
if _, err := d.runner.Run(ctx, "ip", "link", "show", d.config.BridgeName); err == nil {
|
||||
_, err = d.runner.RunSudo(ctx, "ip", "link", "set", d.config.BridgeName, "up")
|
||||
return err
|
||||
}
|
||||
if _, err := d.runner.RunSudo(ctx, "ip", "link", "add", "name", d.config.BridgeName, "type", "bridge"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := d.runner.RunSudo(ctx, "ip", "addr", "add", fmt.Sprintf("%s/%s", d.config.BridgeIP, d.config.CIDR), "dev", d.config.BridgeName); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := d.runner.RunSudo(ctx, "ip", "link", "set", d.config.BridgeName, "up")
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureSocketDir() error {
|
||||
return os.MkdirAll(d.layout.RuntimeDir, 0o755)
|
||||
}
|
||||
|
||||
func (d *Daemon) createTap(ctx context.Context, tap string) error {
|
||||
if _, err := d.runner.Run(ctx, "ip", "link", "show", tap); err == nil {
|
||||
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", tap)
|
||||
}
|
||||
if _, err := d.runner.RunSudo(ctx, "ip", "tuntap", "add", "dev", tap, "mode", "tap", "user", strconv.Itoa(os.Getuid()), "group", strconv.Itoa(os.Getgid())); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := d.runner.RunSudo(ctx, "ip", "link", "set", tap, "master", d.config.BridgeName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := d.runner.RunSudo(ctx, "ip", "link", "set", tap, "up"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := d.runner.RunSudo(ctx, "ip", "link", "set", d.config.BridgeName, "up")
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) firecrackerBinary() (string, error) {
|
||||
if d.config.RepoRoot == "" {
|
||||
return "", errors.New("repo root not detected")
|
||||
}
|
||||
path := filepath.Join(d.config.RepoRoot, "firecracker")
|
||||
if !exists(path) {
|
||||
return "", fmt.Errorf("firecracker binary not found at %s", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) startFirecrackerProcess(ctx context.Context, fcBin, apiSock, logPath string) (int, error) {
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "sudo", "-n", fcBin, "--api-sock", apiSock)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = logFile.Close()
|
||||
return 0, err
|
||||
}
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
_ = logFile.Close()
|
||||
}()
|
||||
return cmd.Process.Pid, nil
|
||||
}
|
||||
|
||||
func (d *Daemon) waitForSocket(ctx context.Context, apiSock string) error {
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var lastErr error
|
||||
for {
|
||||
if _, err := os.Stat(apiSock); err == nil {
|
||||
if err := d.ensureSocketAccess(ctx, apiSock); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
conn, dialErr := net.DialTimeout("unix", apiSock, 200*time.Millisecond)
|
||||
if dialErr == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
lastErr = err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("firecracker api socket not ready: %s: %w", apiSock, lastErr)
|
||||
}
|
||||
return fmt.Errorf("firecracker api socket not ready: %s", apiSock)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureSocketAccess(ctx context.Context, apiSock string) error {
|
||||
if _, err := d.runner.RunSudo(ctx, "chown", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), apiSock); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := d.runner.RunSudo(ctx, "chmod", "600", apiSock)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) findFirecrackerPID(ctx context.Context, apiSock string) (int, error) {
|
||||
out, err := d.runner.Run(ctx, "pgrep", "-n", "-f", apiSock)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
func (d *Daemon) sendCtrlAltDel(ctx context.Context, vm model.VMRecord) error {
|
||||
if err := d.ensureSocketAccess(ctx, vm.Runtime.APISockPath); err != nil {
|
||||
return err
|
||||
}
|
||||
client := firecracker.New(vm.Runtime.APISockPath)
|
||||
return client.Put(ctx, "/actions", map[string]any{"action_type": "SendCtrlAltDel"})
|
||||
}
|
||||
|
||||
func (d *Daemon) waitForExit(ctx context.Context, pid int, apiSock string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if !system.ProcessRunning(pid, apiSock) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timed out waiting for VM to exit")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) cleanupRuntime(ctx context.Context, vm model.VMRecord, preserveDisks bool) error {
|
||||
if vm.Runtime.PID > 0 && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
_ = d.killVMProcess(ctx, vm.Runtime.PID)
|
||||
}
|
||||
if vm.Runtime.TapDevice != "" {
|
||||
_, _ = d.runner.RunSudo(ctx, "ip", "link", "del", vm.Runtime.TapDevice)
|
||||
}
|
||||
if vm.Runtime.APISockPath != "" {
|
||||
_ = os.Remove(vm.Runtime.APISockPath)
|
||||
}
|
||||
if vm.Runtime.DMName != "" {
|
||||
_, _ = d.runner.RunSudo(ctx, "dmsetup", "remove", vm.Runtime.DMName)
|
||||
} else if vm.Runtime.DMDev != "" {
|
||||
_, _ = d.runner.RunSudo(ctx, "dmsetup", "remove", vm.Runtime.DMDev)
|
||||
}
|
||||
if vm.Runtime.COWLoop != "" {
|
||||
_, _ = d.runner.RunSudo(ctx, "losetup", "-d", vm.Runtime.COWLoop)
|
||||
}
|
||||
if vm.Runtime.BaseLoop != "" {
|
||||
_, _ = d.runner.RunSudo(ctx, "losetup", "-d", vm.Runtime.BaseLoop)
|
||||
}
|
||||
if vm.Spec.NATEnabled {
|
||||
_ = d.ensureNAT(ctx, vm, false)
|
||||
}
|
||||
_ = d.removeDNS(ctx, vm.Runtime.DNSName)
|
||||
if !preserveDisks && vm.Runtime.VMDir != "" {
|
||||
return os.RemoveAll(vm.Runtime.VMDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearRuntimeHandles(vm *model.VMRecord) {
|
||||
vm.Runtime.PID = 0
|
||||
vm.Runtime.APISockPath = ""
|
||||
vm.Runtime.TapDevice = ""
|
||||
vm.Runtime.BaseLoop = ""
|
||||
vm.Runtime.COWLoop = ""
|
||||
vm.Runtime.DMName = ""
|
||||
vm.Runtime.DMDev = ""
|
||||
vm.Runtime.FirecrackerState = nil
|
||||
}
|
||||
|
||||
func (d *Daemon) setDNS(ctx context.Context, vmName, guestIP string) error {
|
||||
_, err := d.runner.Run(ctx, "mapdns", "set", "--data-file", "/home/thales/.local/share/mapdns/records.json", vmName+".vm", guestIP)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) removeDNS(ctx context.Context, dnsName string) error {
|
||||
if dnsName == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := d.runner.Run(ctx, "mapdns", "rm", "--data-file", "/home/thales/.local/share/mapdns/records.json", dnsName)
|
||||
if err != nil && strings.Contains(err.Error(), "not found") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) error {
|
||||
if d.config.RepoRoot == "" {
|
||||
return errors.New("repo root not detected")
|
||||
}
|
||||
script := filepath.Join(d.config.RepoRoot, "nat.sh")
|
||||
action := "down"
|
||||
if enable {
|
||||
action = "up"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "bash", script, action, vm.ID)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Dir = d.config.RepoRoot
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (d *Daemon) killVMProcess(ctx context.Context, pid int) error {
|
||||
_, err := d.runner.RunSudo(ctx, "kill", "-KILL", strconv.Itoa(pid))
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) requireStartPrereqs(ctx context.Context) error {
|
||||
return system.RequireCommands(
|
||||
ctx,
|
||||
"sudo",
|
||||
"ip",
|
||||
"curl",
|
||||
"jq",
|
||||
"dmsetup",
|
||||
"losetup",
|
||||
"blockdev",
|
||||
"e2cp",
|
||||
"e2rm",
|
||||
"debugfs",
|
||||
"mkfs.ext4",
|
||||
"truncate",
|
||||
"pgrep",
|
||||
"mount",
|
||||
"umount",
|
||||
"cp",
|
||||
"ps",
|
||||
"mapdns",
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Daemon) generateName(ctx context.Context) (string, error) {
|
||||
if d.config.RepoRoot != "" {
|
||||
namegen := filepath.Join(d.config.RepoRoot, "namegen")
|
||||
if exists(namegen) {
|
||||
out, err := d.runner.Run(ctx, namegen)
|
||||
if err == nil {
|
||||
name := strings.TrimSpace(string(out))
|
||||
if name != "" {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "vm-" + strconv.FormatInt(time.Now().Unix(), 10), nil
|
||||
}
|
||||
|
||||
func bridgePrefix(bridgeIP string) string {
|
||||
parts := strings.Split(bridgeIP, ".")
|
||||
if len(parts) < 3 {
|
||||
return bridgeIP
|
||||
}
|
||||
return strings.Join(parts[:3], ".")
|
||||
}
|
||||
|
||||
func defaultInt(value, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
67
internal/firecracker/client.go
Normal file
67
internal/firecracker/client.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package firecracker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"banger/internal/rpc"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(apiSock string) *Client {
|
||||
return &Client{http: rpc.NewUnixHTTPClient(apiSock)}
|
||||
}
|
||||
|
||||
func (c *Client) Put(ctx context.Context, path string, body any) error {
|
||||
var payload io.Reader = http.NoBody
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://localhost"+path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("firecracker %s failed: %s", path, bytes.TrimSpace(data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetConfig(ctx context.Context) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost/vm/config", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("firecracker config failed: %s", bytes.TrimSpace(data))
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
213
internal/model/types.go
Normal file
213
internal/model/types.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBridgeName = "br-fc"
|
||||
DefaultBridgeIP = "172.16.0.1"
|
||||
DefaultCIDR = "24"
|
||||
DefaultDNS = "1.1.1.1"
|
||||
DefaultSystemOverlaySize = 8 * 1024 * 1024 * 1024
|
||||
DefaultWorkDiskSize = 8 * 1024 * 1024 * 1024
|
||||
DefaultMemoryMiB = 1024
|
||||
DefaultVCPUCount = 2
|
||||
DefaultStatsPollInterval = 10 * time.Second
|
||||
DefaultStaleSweepInterval = 1 * time.Minute
|
||||
DefaultMetricsPollInterval = 15 * time.Second
|
||||
MaxDiskBytes int64 = 128 * 1024 * 1024 * 1024
|
||||
)
|
||||
|
||||
type VMState string
|
||||
|
||||
const (
|
||||
VMStateCreated VMState = "created"
|
||||
VMStateRunning VMState = "running"
|
||||
VMStateStopped VMState = "stopped"
|
||||
VMStateError VMState = "error"
|
||||
)
|
||||
|
||||
type DaemonConfig struct {
|
||||
RepoRoot string
|
||||
AutoStopStaleAfter time.Duration
|
||||
StatsPollInterval time.Duration
|
||||
MetricsPollInterval time.Duration
|
||||
BridgeName string
|
||||
BridgeIP string
|
||||
CIDR string
|
||||
DefaultDNS string
|
||||
DefaultImageName string
|
||||
DefaultBaseRootfs string
|
||||
DefaultKernel string
|
||||
DefaultInitrd string
|
||||
DefaultModulesDir string
|
||||
DefaultPackagesFile string
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Managed bool `json:"managed"`
|
||||
ArtifactDir string `json:"artifact_dir,omitempty"`
|
||||
RootfsPath string `json:"rootfs_path"`
|
||||
KernelPath string `json:"kernel_path"`
|
||||
InitrdPath string `json:"initrd_path,omitempty"`
|
||||
ModulesDir string `json:"modules_dir,omitempty"`
|
||||
PackagesPath string `json:"packages_path,omitempty"`
|
||||
BuildSize string `json:"build_size,omitempty"`
|
||||
Docker bool `json:"docker"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type VMSpec struct {
|
||||
VCPUCount int `json:"vcpu_count"`
|
||||
MemoryMiB int `json:"memory_mib"`
|
||||
SystemOverlaySizeByte int64 `json:"system_overlay_size_bytes"`
|
||||
WorkDiskSizeBytes int64 `json:"work_disk_size_bytes"`
|
||||
NATEnabled bool `json:"nat_enabled"`
|
||||
}
|
||||
|
||||
type VMRuntime struct {
|
||||
State VMState `json:"state"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
GuestIP string `json:"guest_ip"`
|
||||
TapDevice string `json:"tap_device,omitempty"`
|
||||
APISockPath string `json:"api_sock_path,omitempty"`
|
||||
LogPath string `json:"log_path,omitempty"`
|
||||
MetricsPath string `json:"metrics_path,omitempty"`
|
||||
DNSName string `json:"dns_name,omitempty"`
|
||||
VMDir string `json:"vm_dir"`
|
||||
SystemOverlay string `json:"system_overlay_path"`
|
||||
WorkDiskPath string `json:"work_disk_path"`
|
||||
BaseLoop string `json:"base_loop,omitempty"`
|
||||
COWLoop string `json:"cow_loop,omitempty"`
|
||||
DMName string `json:"dm_name,omitempty"`
|
||||
DMDev string `json:"dm_dev,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
FirecrackerState map[string]any `json:"firecracker_state,omitempty"`
|
||||
}
|
||||
|
||||
type VMStats struct {
|
||||
CollectedAt time.Time `json:"collected_at,omitempty"`
|
||||
CPUPercent float64 `json:"cpu_percent,omitempty"`
|
||||
RSSBytes int64 `json:"rss_bytes,omitempty"`
|
||||
VSZBytes int64 `json:"vsz_bytes,omitempty"`
|
||||
SystemOverlayBytes int64 `json:"system_overlay_bytes,omitempty"`
|
||||
WorkDiskBytes int64 `json:"work_disk_bytes,omitempty"`
|
||||
MetricsRaw map[string]any `json:"metrics_raw,omitempty"`
|
||||
}
|
||||
|
||||
type VMRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageID string `json:"image_id"`
|
||||
State VMState `json:"state"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastTouchedAt time.Time `json:"last_touched_at"`
|
||||
Spec VMSpec `json:"spec"`
|
||||
Runtime VMRuntime `json:"runtime"`
|
||||
Stats VMStats `json:"stats"`
|
||||
}
|
||||
|
||||
type VMCreateRequest struct {
|
||||
Name string
|
||||
ImageName string
|
||||
VCPUCount int
|
||||
MemoryMiB int
|
||||
SystemOverlaySizeByte int64
|
||||
WorkDiskSizeBytes int64
|
||||
NATEnabled bool
|
||||
NoStart bool
|
||||
}
|
||||
|
||||
type VMSetRequest struct {
|
||||
IDOrName string
|
||||
VCPUCount *int
|
||||
MemoryMiB *int
|
||||
WorkDiskSizeBytes *int64
|
||||
NATEnabled *bool
|
||||
}
|
||||
|
||||
type ImageBuildRequest struct {
|
||||
Name string
|
||||
BaseRootfs string
|
||||
Size string
|
||||
KernelPath string
|
||||
InitrdPath string
|
||||
ModulesDir string
|
||||
Docker bool
|
||||
}
|
||||
|
||||
func Now() time.Time {
|
||||
return time.Now().UTC().Truncate(time.Second)
|
||||
}
|
||||
|
||||
func NewID() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func ParseSize(raw string) (int64, error) {
|
||||
if raw == "" {
|
||||
return 0, errors.New("size is required")
|
||||
}
|
||||
raw = strings.TrimSpace(strings.ToUpper(raw))
|
||||
if raw == "" {
|
||||
return 0, errors.New("size is required")
|
||||
}
|
||||
unit := raw[len(raw)-1]
|
||||
multiplier := int64(1024 * 1024)
|
||||
number := raw
|
||||
switch unit {
|
||||
case 'K':
|
||||
multiplier = 1024
|
||||
number = raw[:len(raw)-1]
|
||||
case 'M':
|
||||
multiplier = 1024 * 1024
|
||||
number = raw[:len(raw)-1]
|
||||
case 'G':
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
number = raw[:len(raw)-1]
|
||||
default:
|
||||
if unit < '0' || unit > '9' {
|
||||
return 0, fmt.Errorf("unsupported size suffix: %q", string(unit))
|
||||
}
|
||||
}
|
||||
value, err := strconv.ParseInt(number, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse size %q: %w", raw, err)
|
||||
}
|
||||
result := value * multiplier
|
||||
if result <= 0 {
|
||||
return 0, fmt.Errorf("size must be positive: %q", raw)
|
||||
}
|
||||
if result > MaxDiskBytes {
|
||||
return 0, fmt.Errorf("size exceeds max of %d bytes", MaxDiskBytes)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func FormatSizeBytes(bytes int64) string {
|
||||
switch {
|
||||
case bytes%(1024*1024*1024) == 0:
|
||||
return fmt.Sprintf("%dG", bytes/(1024*1024*1024))
|
||||
case bytes%(1024*1024) == 0:
|
||||
return fmt.Sprintf("%dM", bytes/(1024*1024))
|
||||
case bytes%1024 == 0:
|
||||
return fmt.Sprintf("%dK", bytes/1024)
|
||||
default:
|
||||
return strconv.FormatInt(bytes, 10)
|
||||
}
|
||||
}
|
||||
154
internal/paths/paths.go
Normal file
154
internal/paths/paths.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package paths
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Layout struct {
|
||||
ConfigHome string
|
||||
StateHome string
|
||||
CacheHome string
|
||||
RuntimeHome string
|
||||
ConfigDir string
|
||||
StateDir string
|
||||
CacheDir string
|
||||
RuntimeDir string
|
||||
SocketPath string
|
||||
DBPath string
|
||||
DaemonLog string
|
||||
VMsDir string
|
||||
ImagesDir string
|
||||
}
|
||||
|
||||
func Resolve() (Layout, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return Layout{}, err
|
||||
}
|
||||
configHome := getenvDefault("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
stateHome := getenvDefault("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
|
||||
cacheHome := getenvDefault("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
runtimeHome := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if runtimeHome == "" {
|
||||
runtimeHome = filepath.Join(os.TempDir(), fmt.Sprintf("banger-runtime-%d", os.Getuid()))
|
||||
}
|
||||
|
||||
layout := Layout{
|
||||
ConfigHome: configHome,
|
||||
StateHome: stateHome,
|
||||
CacheHome: cacheHome,
|
||||
RuntimeHome: runtimeHome,
|
||||
ConfigDir: filepath.Join(configHome, "banger"),
|
||||
StateDir: filepath.Join(stateHome, "banger"),
|
||||
CacheDir: filepath.Join(cacheHome, "banger"),
|
||||
RuntimeDir: filepath.Join(runtimeHome, "banger"),
|
||||
}
|
||||
layout.SocketPath = filepath.Join(layout.RuntimeDir, "bangerd.sock")
|
||||
layout.DBPath = filepath.Join(layout.StateDir, "state.db")
|
||||
layout.DaemonLog = filepath.Join(layout.StateDir, "bangerd.log")
|
||||
layout.VMsDir = filepath.Join(layout.StateDir, "vms")
|
||||
layout.ImagesDir = filepath.Join(layout.StateDir, "images")
|
||||
return layout, nil
|
||||
}
|
||||
|
||||
func Ensure(layout Layout) error {
|
||||
for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DetectRepoRoot() string {
|
||||
if env := os.Getenv("BANGER_REPO_ROOT"); env != "" {
|
||||
return env
|
||||
}
|
||||
candidates := []string{}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, wd)
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
candidates = append(candidates, filepath.Dir(exe))
|
||||
}
|
||||
if look, err := exec.LookPath("firecracker"); err == nil {
|
||||
candidates = append(candidates, filepath.Dir(look))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if root := walkForRepoRoot(candidate); root != "" {
|
||||
return root
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func walkForRepoRoot(start string) string {
|
||||
current := start
|
||||
for {
|
||||
if hasRepoArtifacts(current) {
|
||||
return current
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return ""
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func hasRepoArtifacts(dir string) bool {
|
||||
required := []string{"firecracker", "README.md"}
|
||||
for _, name := range required {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func BangerdPath() (string, error) {
|
||||
if env := os.Getenv("BANGER_DAEMON_BIN"); env != "" {
|
||||
return env, nil
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
for _, candidate := range []string{
|
||||
filepath.Join(dir, "bangerd"),
|
||||
filepath.Join(dir, "bangerd.exe"),
|
||||
} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
if root := DetectRepoRoot(); root != "" {
|
||||
for _, candidate := range []string{
|
||||
filepath.Join(root, "bangerd"),
|
||||
filepath.Join(root, "bangerd.exe"),
|
||||
} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("bangerd binary not found next to banger; build ./cmd/bangerd")
|
||||
}
|
||||
|
||||
func getenvDefault(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func RuntimeFallbackLabel() string {
|
||||
return strconv.Itoa(os.Getuid())
|
||||
}
|
||||
128
internal/rpc/rpc.go
Normal file
128
internal/rpc/rpc.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Version = 1
|
||||
|
||||
type Request struct {
|
||||
Version int `json:"version"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
OK bool `json:"ok"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *ErrorResponse `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func NewResult(v any) (Response, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
return Response{OK: true, Result: data}, nil
|
||||
}
|
||||
|
||||
func NewError(code, message string) Response {
|
||||
return Response{OK: false, Error: &ErrorResponse{Code: code, Message: message}}
|
||||
}
|
||||
|
||||
func DecodeParams[T any](req Request) (T, error) {
|
||||
var zero T
|
||||
if len(req.Params) == 0 {
|
||||
return zero, nil
|
||||
}
|
||||
var out T
|
||||
if err := json.Unmarshal(req.Params, &out); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func Call[T any](ctx context.Context, socketPath, method string, params any) (T, error) {
|
||||
var zero T
|
||||
conn, err := net.DialTimeout("unix", socketPath, 2*time.Second)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
request := Request{Version: Version, Method: method}
|
||||
if params != nil {
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
request.Params = raw
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(conn).Encode(request); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&response); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if !response.OK {
|
||||
if response.Error == nil {
|
||||
return zero, errors.New("rpc error")
|
||||
}
|
||||
return zero, fmt.Errorf("%s: %s", response.Error.Code, response.Error.Message)
|
||||
}
|
||||
if len(response.Result) == 0 {
|
||||
return zero, nil
|
||||
}
|
||||
var result T
|
||||
if err := json.Unmarshal(response.Result, &result); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func WaitForSocket(path string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
conn, err := net.DialTimeout("unix", path, 500*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("socket %s not ready", path)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func NewUnixHTTPClient(socketPath string) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
386
internal/store/store.go
Normal file
386
internal/store/store.go
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"banger/internal/model"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{db: db}
|
||||
if err := store.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
stmts := []string{
|
||||
`PRAGMA journal_mode=WAL;`,
|
||||
`CREATE TABLE IF NOT EXISTS images (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
managed INTEGER NOT NULL DEFAULT 0,
|
||||
artifact_dir TEXT,
|
||||
rootfs_path TEXT NOT NULL,
|
||||
kernel_path TEXT NOT NULL,
|
||||
initrd_path TEXT,
|
||||
modules_dir TEXT,
|
||||
packages_path TEXT,
|
||||
build_size TEXT,
|
||||
docker INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS vms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
image_id TEXT NOT NULL,
|
||||
guest_ip TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_touched_at TEXT NOT NULL,
|
||||
spec_json TEXT NOT NULL,
|
||||
runtime_json TEXT NOT NULL,
|
||||
stats_json TEXT NOT NULL DEFAULT '{}',
|
||||
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
|
||||
);`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertImage(ctx context.Context, image model.Image) error {
|
||||
const query = `
|
||||
INSERT INTO images (
|
||||
id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path,
|
||||
modules_dir, packages_path, build_size, docker, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
managed=excluded.managed,
|
||||
artifact_dir=excluded.artifact_dir,
|
||||
rootfs_path=excluded.rootfs_path,
|
||||
kernel_path=excluded.kernel_path,
|
||||
initrd_path=excluded.initrd_path,
|
||||
modules_dir=excluded.modules_dir,
|
||||
packages_path=excluded.packages_path,
|
||||
build_size=excluded.build_size,
|
||||
docker=excluded.docker,
|
||||
updated_at=excluded.updated_at`
|
||||
_, err := s.db.ExecContext(ctx, query,
|
||||
image.ID,
|
||||
image.Name,
|
||||
boolToInt(image.Managed),
|
||||
image.ArtifactDir,
|
||||
image.RootfsPath,
|
||||
image.KernelPath,
|
||||
image.InitrdPath,
|
||||
image.ModulesDir,
|
||||
image.PackagesPath,
|
||||
image.BuildSize,
|
||||
boolToInt(image.Docker),
|
||||
image.CreatedAt.Format(time.RFC3339),
|
||||
image.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetImageByName(ctx context.Context, name string) (model.Image, error) {
|
||||
return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE name = ?", name)
|
||||
}
|
||||
|
||||
func (s *Store) GetImageByID(ctx context.Context, id string) (model.Image, error) {
|
||||
return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE id = ?", id)
|
||||
}
|
||||
|
||||
func (s *Store) ListImages(ctx context.Context) ([]model.Image, error) {
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images ORDER BY created_at ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var images []model.Image
|
||||
for rows.Next() {
|
||||
image, err := scanImage(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
images = append(images, image)
|
||||
}
|
||||
return images, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteImage(ctx context.Context, id string) error {
|
||||
_, err := s.db.ExecContext(ctx, "DELETE FROM images WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertVM(ctx context.Context, vm model.VMRecord) error {
|
||||
specJSON, err := json.Marshal(vm.Spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeJSON, err := json.Marshal(vm.Runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
statsJSON, err := json.Marshal(vm.Stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
const query = `
|
||||
INSERT INTO vms (
|
||||
id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
||||
spec_json, runtime_json, stats_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
image_id=excluded.image_id,
|
||||
guest_ip=excluded.guest_ip,
|
||||
state=excluded.state,
|
||||
updated_at=excluded.updated_at,
|
||||
last_touched_at=excluded.last_touched_at,
|
||||
spec_json=excluded.spec_json,
|
||||
runtime_json=excluded.runtime_json,
|
||||
stats_json=excluded.stats_json`
|
||||
_, err = s.db.ExecContext(ctx, query,
|
||||
vm.ID,
|
||||
vm.Name,
|
||||
vm.ImageID,
|
||||
vm.Runtime.GuestIP,
|
||||
string(vm.State),
|
||||
vm.CreatedAt.Format(time.RFC3339),
|
||||
vm.UpdatedAt.Format(time.RFC3339),
|
||||
vm.LastTouchedAt.Format(time.RFC3339),
|
||||
string(specJSON),
|
||||
string(runtimeJSON),
|
||||
string(statsJSON),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||
const query = `
|
||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
||||
spec_json, runtime_json, stats_json
|
||||
FROM vms
|
||||
WHERE id = ? OR name = ?
|
||||
`
|
||||
row := s.db.QueryRowContext(ctx, query, idOrName, idOrName)
|
||||
return scanVMRow(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetVMByID(ctx context.Context, id string) (model.VMRecord, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
||||
spec_json, runtime_json, stats_json
|
||||
FROM vms WHERE id = ?`, id)
|
||||
return scanVMRow(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListVMs(ctx context.Context) ([]model.VMRecord, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
||||
spec_json, runtime_json, stats_json
|
||||
FROM vms ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var vms []model.VMRecord
|
||||
for rows.Next() {
|
||||
vm, err := scanVMRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vms = append(vms, vm)
|
||||
}
|
||||
return vms, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteVM(ctx context.Context, id string) error {
|
||||
_, err := s.db.ExecContext(ctx, "DELETE FROM vms WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.VMRecord, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
||||
spec_json, runtime_json, stats_json
|
||||
FROM vms WHERE image_id = ?`, imageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var vms []model.VMRecord
|
||||
for rows.Next() {
|
||||
vm, err := scanVMRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vms = append(vms, vm)
|
||||
}
|
||||
return vms, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
|
||||
used := map[string]struct{}{}
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ip string
|
||||
if err := rows.Scan(&ip); err != nil {
|
||||
return "", err
|
||||
}
|
||||
used[ip] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := 2; i < 255; i++ {
|
||||
candidate := fmt.Sprintf("%s.%d", bridgeIPPrefix, i)
|
||||
if _, exists := used[candidate]; !exists {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("no guest IPs available")
|
||||
}
|
||||
|
||||
func (s *Store) getImage(ctx context.Context, query string, arg string) (model.Image, error) {
|
||||
row := s.db.QueryRowContext(ctx, query, arg)
|
||||
return scanImageRow(row)
|
||||
}
|
||||
|
||||
func scanImage(rows scanner) (model.Image, error) {
|
||||
return scanImageRow(rows)
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanImageRow(row scanner) (model.Image, error) {
|
||||
var image model.Image
|
||||
var managed, docker int
|
||||
var createdAt, updatedAt string
|
||||
err := row.Scan(
|
||||
&image.ID,
|
||||
&image.Name,
|
||||
&managed,
|
||||
&image.ArtifactDir,
|
||||
&image.RootfsPath,
|
||||
&image.KernelPath,
|
||||
&image.InitrdPath,
|
||||
&image.ModulesDir,
|
||||
&image.PackagesPath,
|
||||
&image.BuildSize,
|
||||
&docker,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return image, err
|
||||
}
|
||||
image.Managed = managed == 1
|
||||
image.Docker = docker == 1
|
||||
image.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
|
||||
if err != nil {
|
||||
return image, err
|
||||
}
|
||||
image.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt)
|
||||
if err != nil {
|
||||
return image, err
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
func scanVMRow(row scanner) (model.VMRecord, error) {
|
||||
return scanVMInto(row)
|
||||
}
|
||||
|
||||
func scanVMRows(rows scanner) (model.VMRecord, error) {
|
||||
return scanVMInto(rows)
|
||||
}
|
||||
|
||||
func scanVMInto(row scanner) (model.VMRecord, error) {
|
||||
var vm model.VMRecord
|
||||
var state, createdAt, updatedAt, touchedAt, specJSON, runtimeJSON, statsJSON string
|
||||
err := row.Scan(
|
||||
&vm.ID,
|
||||
&vm.Name,
|
||||
&vm.ImageID,
|
||||
&vm.Runtime.GuestIP,
|
||||
&state,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
&touchedAt,
|
||||
&specJSON,
|
||||
&runtimeJSON,
|
||||
&statsJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return vm, err
|
||||
}
|
||||
vm.State = model.VMState(state)
|
||||
if err := json.Unmarshal([]byte(specJSON), &vm.Spec); err != nil {
|
||||
return vm, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(runtimeJSON), &vm.Runtime); err != nil {
|
||||
return vm, err
|
||||
}
|
||||
if statsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(statsJSON), &vm.Stats); err != nil {
|
||||
return vm, err
|
||||
}
|
||||
}
|
||||
var parseErr error
|
||||
vm.CreatedAt, parseErr = time.Parse(time.RFC3339, createdAt)
|
||||
if parseErr != nil {
|
||||
return vm, parseErr
|
||||
}
|
||||
vm.UpdatedAt, parseErr = time.Parse(time.RFC3339, updatedAt)
|
||||
if parseErr != nil {
|
||||
return vm, parseErr
|
||||
}
|
||||
vm.LastTouchedAt, parseErr = time.Parse(time.RFC3339, touchedAt)
|
||||
if parseErr != nil {
|
||||
return vm, parseErr
|
||||
}
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
321
internal/system/system.go
Normal file
321
internal/system/system.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"banger/internal/model"
|
||||
)
|
||||
|
||||
type Runner struct{}
|
||||
|
||||
func NewRunner() Runner {
|
||||
return Runner{}
|
||||
}
|
||||
|
||||
func (Runner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if stderr.Len() > 0 {
|
||||
return stdout.Bytes(), fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return stdout.Bytes(), err
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func (r Runner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
|
||||
all := append([]string{"-n"}, args...)
|
||||
return r.Run(ctx, "sudo", all...)
|
||||
}
|
||||
|
||||
func EnsureSudo(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "sudo", "-v")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func RequireCommands(ctx context.Context, commands ...string) error {
|
||||
for _, command := range commands {
|
||||
if _, err := exec.LookPath(command); err != nil {
|
||||
return fmt.Errorf("required command %q not found", command)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteJSON(path string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func AllocatedBytes(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return info.Size()
|
||||
}
|
||||
return stat.Blocks * 512
|
||||
}
|
||||
|
||||
func ProcessRunning(pid int, apiSock string) bool {
|
||||
if pid <= 0 || apiSock == "" {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cmdline := strings.ReplaceAll(string(data), "\x00", " ")
|
||||
return strings.Contains(cmdline, "firecracker") && strings.Contains(cmdline, apiSock)
|
||||
}
|
||||
|
||||
type ProcessStats struct {
|
||||
CPUPercent float64
|
||||
RSSBytes int64
|
||||
VSZBytes int64
|
||||
}
|
||||
|
||||
func ReadProcessStats(ctx context.Context, pid int) (ProcessStats, error) {
|
||||
if pid <= 0 {
|
||||
return ProcessStats{}, errors.New("pid is required")
|
||||
}
|
||||
runner := NewRunner()
|
||||
out, err := runner.Run(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "%cpu=,rss=,vsz=")
|
||||
if err != nil {
|
||||
return ProcessStats{}, err
|
||||
}
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) < 3 {
|
||||
return ProcessStats{}, fmt.Errorf("unexpected ps output: %q", string(out))
|
||||
}
|
||||
cpu, _ := strconv.ParseFloat(fields[0], 64)
|
||||
rssKB, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
vszKB, _ := strconv.ParseInt(fields[2], 10, 64)
|
||||
return ProcessStats{
|
||||
CPUPercent: cpu,
|
||||
RSSBytes: rssKB * 1024,
|
||||
VSZBytes: vszKB * 1024,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TailCommand(path string, follow bool) *exec.Cmd {
|
||||
if follow {
|
||||
return exec.Command("tail", "-f", path)
|
||||
}
|
||||
return exec.Command("cat", path)
|
||||
}
|
||||
|
||||
func ParseMetricsFile(path string) map[string]any {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(bytes.TrimSpace(data)) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw := bytes.TrimSpace(data)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(raw, &result); err == nil {
|
||||
return result
|
||||
}
|
||||
lastLine := lastJSONLine(raw)
|
||||
if lastLine == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(lastLine, &result); err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func lastJSONLine(data []byte) []byte {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
var last []byte
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
last = append([]byte(nil), line...)
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func CopyDirContents(ctx context.Context, runner Runner, sourceDir, targetDir string, useSudo bool) error {
|
||||
args := []string{"-a", filepath.Join(sourceDir, "."), targetDir + "/"}
|
||||
var err error
|
||||
if useSudo {
|
||||
_, err = runner.RunSudo(ctx, append([]string{"cp"}, args...)...)
|
||||
} else {
|
||||
_, err = runner.Run(ctx, "cp", args...)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ResizeExt4Image(ctx context.Context, runner Runner, path string, bytes int64) error {
|
||||
if _, err := runner.Run(ctx, "truncate", "-s", strconv.FormatInt(bytes, 10), path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runner.Run(ctx, "e2fsck", "-p", "-f", path); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := runner.Run(ctx, "resize2fs", path)
|
||||
return err
|
||||
}
|
||||
|
||||
func ReadDebugFSText(ctx context.Context, runner Runner, imagePath, guestPath string) (string, error) {
|
||||
out, err := runner.Run(ctx, "debugfs", "-R", "cat "+guestPath, imagePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func WriteExt4File(ctx context.Context, runner Runner, imagePath, guestPath string, data []byte) error {
|
||||
tmp, err := os.CreateTemp("", "banger-ext4-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = runner.RunSudo(ctx, "e2rm", imagePath+":"+guestPath)
|
||||
_, err = runner.RunSudo(ctx, "e2cp", tmp.Name(), imagePath+":"+guestPath)
|
||||
return err
|
||||
}
|
||||
|
||||
func MountTempDir(ctx context.Context, runner Runner, source string, readOnly bool) (string, func() error, error) {
|
||||
mountDir, err := os.MkdirTemp("", "banger-mnt-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
args := []string{"mount"}
|
||||
var opts []string
|
||||
if readOnly {
|
||||
opts = append(opts, "ro")
|
||||
}
|
||||
if useLoopMount(source) {
|
||||
opts = append(opts, "loop")
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
args = append(args, "-o", strings.Join(opts, ","))
|
||||
}
|
||||
args = append(args, source, mountDir)
|
||||
if _, err := runner.RunSudo(ctx, args...); err != nil {
|
||||
_ = os.RemoveAll(mountDir)
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() error {
|
||||
_, err := runner.RunSudo(context.Background(), "umount", mountDir)
|
||||
_ = os.RemoveAll(mountDir)
|
||||
return err
|
||||
}
|
||||
return mountDir, cleanup, nil
|
||||
}
|
||||
|
||||
func useLoopMount(source string) bool {
|
||||
info, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
func UpdateFSTab(existing string) string {
|
||||
lines := strings.Split(existing, "\n")
|
||||
var out []string
|
||||
hasRoot := false
|
||||
hasRun := false
|
||||
hasTmp := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) >= 2 {
|
||||
if fields[0] == "/dev/vdb" && fields[1] == "/home" {
|
||||
continue
|
||||
}
|
||||
if fields[0] == "/dev/vdc" && fields[1] == "/var" {
|
||||
continue
|
||||
}
|
||||
if fields[0] == "/dev/vdb" && fields[1] == "/root" {
|
||||
hasRoot = true
|
||||
}
|
||||
if fields[0] == "tmpfs" && fields[1] == "/run" {
|
||||
hasRun = true
|
||||
}
|
||||
if fields[0] == "tmpfs" && fields[1] == "/tmp" {
|
||||
hasTmp = true
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if !hasRoot {
|
||||
out = append(out, "/dev/vdb /root ext4 defaults 0 2")
|
||||
}
|
||||
if !hasRun {
|
||||
out = append(out, "tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0")
|
||||
}
|
||||
if !hasTmp {
|
||||
out = append(out, "tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0")
|
||||
}
|
||||
return strings.Join(out, "\n") + "\n"
|
||||
}
|
||||
|
||||
func BuildBootArgs(vmName, guestIP, bridgeIP, dns string) string {
|
||||
return fmt.Sprintf(
|
||||
"console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=%s::%s:255.255.255.0::eth0:off:%s hostname=%s systemd.mask=home.mount systemd.mask=var.mount",
|
||||
guestIP,
|
||||
bridgeIP,
|
||||
dns,
|
||||
vmName,
|
||||
)
|
||||
}
|
||||
|
||||
func ShortID(id string) string {
|
||||
if len(id) <= 8 {
|
||||
return id
|
||||
}
|
||||
return id[:8]
|
||||
}
|
||||
|
||||
func TouchNow(vm *model.VMRecord) {
|
||||
now := model.Now()
|
||||
vm.UpdatedAt = now
|
||||
vm.LastTouchedAt = now
|
||||
}
|
||||
|
||||
func CopyStream(dst io.Writer, cmd *exec.Cmd) error {
|
||||
cmd.Stdout = dst
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue