From 0c80d030818660ca816af9047c9c56fece5e0f57 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Thu, 19 Mar 2026 22:56:16 -0300 Subject: [PATCH 001/257] Remove the banger TUI Hard cut the terminal UI so the supported management surface is the daemon-backed CLI only. Drop the tui subcommand, delete the Bubble Tea implementation and its tests, and keep a regression check that the legacy command is rejected. Prune the Charmbracelet dependencies with go mod tidy and remove the stale README and AGENTS references. Validated with go test ./... and GOCACHE=/tmp/banger-gocache go test ./internal/cli. --- AGENTS.md | 1 - README.md | 7 +- go.mod | 15 +- go.sum | 38 - internal/cli/banger.go | 2 +- internal/cli/cli_test.go | 11 +- internal/cli/tui.go | 1833 -------------------------------------- internal/cli/tui_test.go | 396 -------- 8 files changed, 13 insertions(+), 2290 deletions(-) delete mode 100644 internal/cli/tui.go delete mode 100644 internal/cli/tui_test.go diff --git a/AGENTS.md b/AGENTS.md index b81aaec..9c8df2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,6 @@ - `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. - `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. - `./banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void. -- `./banger tui` launches the terminal UI. - `make test` runs `go test ./...`. - `./verify.sh` runs the smoke test for the Go VM workflow. diff --git a/README.md b/README.md index e4a33de..2282fb7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # banger -Persistent Firecracker development VMs managed through a Go daemon, CLI, and TUI. +Persistent Firecracker development VMs managed through a Go daemon and CLI. ## Requirements - Linux host with KVM (`/dev/kvm` access) @@ -137,11 +137,6 @@ banger vm kill --signal KILL aa12bb34 cc56dd78 banger vm set --nat web-1 web-2 web-3 ``` -Launch the TUI: -```bash -banger tui -``` - ## Daemon The CLI auto-starts `bangerd` when needed. diff --git a/go.mod b/go.mod index 3b9a5ca..3a07334 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,7 @@ 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/firecracker-microvm/firecracker-go-sdk v1.0.0 - github.com/mattn/go-isatty v0.0.20 github.com/miekg/dns v1.1.72 github.com/pelletier/go-toml v1.9.5 github.com/sirupsen/logrus v1.9.4 @@ -21,8 +17,6 @@ require ( github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/containerd/console v1.0.3 // indirect github.com/containerd/fifo v1.0.0 // indirect github.com/containernetworking/cni v1.0.1 // indirect github.com/containernetworking/plugins v1.0.1 // indirect @@ -43,22 +37,16 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/socket v0.2.0 // indirect github.com/mdlayher/vsock v1.1.1 // indirect github.com/mitchellh/mapstructure v1.4.3 // 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/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/errors v0.9.1 // 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 github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 // indirect github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f // indirect @@ -67,7 +55,6 @@ require ( golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.40.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 3ba2450..44fbb17 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,6 @@ github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:l github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -93,15 +91,6 @@ github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3k github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -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/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -132,8 +121,6 @@ github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on github.com/containerd/console v0.0.0-20191206165004-02ecf6a7291e/go.mod h1:8Pf4gM6VEbTNRIT26AyyU7hxdQU3MvAvxVI0sc00XBE= github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= -github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= -github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/containerd v1.2.10/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= @@ -487,9 +474,6 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -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/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -502,14 +486,9 @@ github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kN github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -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.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -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/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -540,17 +519,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= -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/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -651,9 +619,6 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 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/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -662,7 +627,6 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= -github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= @@ -952,12 +916,10 @@ golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 7f1160f..b7f3361 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -60,7 +60,7 @@ func NewBangerCommand() *cobra.Command { RunE: helpNoArgs, } root.CompletionOptions.DisableDefaultCmd = true - root.AddCommand(newDaemonCommand(), newDoctorCommand(), newVMCommand(), newImageCommand(), newTUICommand(), newInternalCommand()) + root.AddCommand(newDaemonCommand(), newDoctorCommand(), newVMCommand(), newImageCommand(), newInternalCommand()) return root } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cbda292..54af725 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -25,12 +25,21 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) { for _, sub := range cmd.Commands() { names = append(names, sub.Name()) } - want := []string{"daemon", "doctor", "image", "internal", "tui", "vm"} + want := []string{"daemon", "doctor", "image", "internal", "vm"} if !reflect.DeepEqual(names, want) { t.Fatalf("subcommands = %v, want %v", names, want) } } +func TestLegacyRemovedCommandIsRejected(t *testing.T) { + cmd := NewBangerCommand() + cmd.SetArgs([]string{"tui"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown command \"tui\"") { + t.Fatalf("Execute() error = %v, want unknown legacy command", err) + } +} + func TestDoctorCommandPrintsReportAndFailsOnHardFailures(t *testing.T) { original := doctorFunc t.Cleanup(func() { diff --git a/internal/cli/tui.go b/internal/cli/tui.go deleted file mode 100644 index 8448c04..0000000 --- a/internal/cli/tui.go +++ /dev/null @@ -1,1833 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "banger/internal/api" - "banger/internal/model" - "banger/internal/paths" - "banger/internal/rpc" - "banger/internal/system" - "banger/internal/vsockagent" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-isatty" - "github.com/spf13/cobra" -) - -const tuiRefreshInterval = 3 * time.Second - -var ( - tuiEnsureDaemonFunc = ensureDaemon - tuiProgramRunner = func(model tuiModel) error { - program := tea.NewProgram(model, tea.WithAltScreen()) - return program.Start() - } - tuiIsTerminal = func(fd uintptr) bool { - return isatty.IsTerminal(fd) - } -) - -type tuiMode int - -const ( - tuiModeBrowse tuiMode = iota - tuiModeForm - tuiModeConfirmDelete -) - -type actionKind string - -const ( - actionCreate actionKind = "create" - actionEdit actionKind = "edit" - actionStart actionKind = "start" - actionStop actionKind = "stop" - actionRestart actionKind = "restart" - actionDelete actionKind = "delete" - actionSSH actionKind = "ssh" - actionLogs actionKind = "logs" -) - -type daemonReadyMsg struct { - generation int - layout paths.Layout - cfg model.DaemonConfig - duration time.Duration - err error -} - -type vmListLoadedMsg struct { - generation int - vms []model.VMRecord - focusID string - duration time.Duration - err error -} - -type imageListLoadedMsg struct { - generation int - images []model.Image - duration time.Duration - err error -} - -type statsLoadedMsg struct { - id string - stats model.VMStats - err error -} - -type actionResultMsg struct { - action actionRequest - focusID string - status string - err error - refresh bool -} - -type externalPreparedMsg struct { - action actionRequest - command *exec.Cmd - doneStatus string - done func(error) tea.Msg - refresh bool - err error -} - -type sudoValidatedMsg struct { - err error -} - -type refreshTickMsg struct{} - -type actionRequest struct { - kind actionKind - id string - name string - create api.VMCreateParams - set api.VMSetParams -} - -type browseKeyMap struct { - refresh key.Binding - create key.Binding - edit key.Binding - start key.Binding - stop key.Binding - restart key.Binding - delete key.Binding - ssh key.Binding - logs key.Binding - help key.Binding - quit key.Binding -} - -func newBrowseKeyMap() browseKeyMap { - return browseKeyMap{ - refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), - create: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")), - edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), - start: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "start")), - stop: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "stop")), - restart: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "restart")), - delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), - ssh: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "ssh")), - logs: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "logs")), - help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), - quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), - } -} - -func (k browseKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.refresh, k.create, k.edit, k.start, k.stop, k.delete, k.ssh, k.logs, k.quit} -} - -func (k browseKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.refresh, k.create, k.edit, k.start, k.stop, k.restart, k.delete}, - {k.ssh, k.logs, k.help, k.quit}, - } -} - -type formKeyMap struct { - next key.Binding - prev key.Binding - change key.Binding - toggle key.Binding - submit key.Binding - cancel key.Binding -} - -func newFormKeyMap() formKeyMap { - return formKeyMap{ - next: key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab", "next")), - prev: key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab", "prev")), - change: key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("left/right", "change")), - toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), - submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save")), - cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), - } -} - -func (k formKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.next, k.change, k.toggle, k.submit, k.cancel} -} - -func (k formKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.next, k.prev, k.change}, {k.toggle, k.submit, k.cancel}} -} - -type confirmKeyMap struct { - confirm key.Binding - cancel key.Binding -} - -func newConfirmKeyMap() confirmKeyMap { - return confirmKeyMap{ - confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("enter", "confirm")), - cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("esc", "cancel")), - } -} - -func (k confirmKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.confirm, k.cancel} -} - -func (k confirmKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.confirm, k.cancel}} -} - -type formFieldKind int - -const ( - formFieldText formFieldKind = iota - formFieldSelect -) - -type formField struct { - label string - kind formFieldKind - input textinput.Model - options []string - index int -} - -func newTextField(label, value string) formField { - input := textinput.New() - input.Prompt = "" - input.SetValue(value) - input.CharLimit = 128 - return formField{ - label: label, - kind: formFieldText, - input: input, - } -} - -func newSelectField(label string, options []string, index int) formField { - if len(options) == 0 { - options = []string{""} - index = 0 - } - if index < 0 || index >= len(options) { - index = 0 - } - return formField{ - label: label, - kind: formFieldSelect, - options: options, - index: index, - } -} - -func (f formField) value() string { - if f.kind == formFieldText { - return f.input.Value() - } - if len(f.options) == 0 { - return "" - } - return f.options[f.index] -} - -type vmForm struct { - mode actionKind - title string - submitLabel string - targetID string - fields []formField - focus int -} - -func newCreateVMForm(images []model.Image, cfg model.DaemonConfig) *vmForm { - imageOptions := imageNames(images) - selectedImage := 0 - if cfg.DefaultImageName != "" { - for i, name := range imageOptions { - if name == cfg.DefaultImageName { - selectedImage = i - break - } - } - } - form := &vmForm{ - mode: actionCreate, - title: "Create VM", - submitLabel: "Create", - fields: []formField{ - newTextField("Name", ""), - newSelectField("Image", imageOptions, selectedImage), - newTextField("VCPU", strconv.Itoa(model.DefaultVCPUCount)), - newTextField("Memory (MiB)", strconv.Itoa(model.DefaultMemoryMiB)), - newTextField("System Overlay", model.FormatSizeBytes(model.DefaultSystemOverlaySize)), - newTextField("Work Disk", model.FormatSizeBytes(model.DefaultWorkDiskSize)), - newSelectField("NAT Enabled", []string{"no", "yes"}, 0), - newSelectField("No Start", []string{"no", "yes"}, 0), - }, - } - form.focusField(0) - return form -} - -func newEditVMForm(vm model.VMRecord) *vmForm { - form := &vmForm{ - mode: actionEdit, - title: "Edit VM", - submitLabel: "Save", - targetID: vm.ID, - fields: []formField{ - newTextField("VCPU", strconv.Itoa(vm.Spec.VCPUCount)), - newTextField("Memory (MiB)", strconv.Itoa(vm.Spec.MemoryMiB)), - newTextField("Work Disk", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)), - newSelectField("NAT Enabled", []string{"no", "yes"}, boolToIndex(vm.Spec.NATEnabled)), - }, - } - form.focusField(0) - return form -} - -func (f *vmForm) focusField(index int) tea.Cmd { - if len(f.fields) == 0 { - f.focus = 0 - return nil - } - if f.focus >= 0 && f.focus < len(f.fields) && f.fields[f.focus].kind == formFieldText { - f.fields[f.focus].input.Blur() - } - f.focus = wrapIndex(index, len(f.fields)) - if f.fields[f.focus].kind == formFieldText { - return f.fields[f.focus].input.Focus() - } - return nil -} - -func (f *vmForm) move(delta int) tea.Cmd { - return f.focusField(f.focus + delta) -} - -func (f *vmForm) setWidth(width int) { - inputWidth := maxInt(12, width-22) - for i := range f.fields { - if f.fields[i].kind == formFieldText { - f.fields[i].input.Width = inputWidth - } - } -} - -func (f *vmForm) update(msg tea.Msg) tea.Cmd { - if len(f.fields) == 0 { - return nil - } - if f.fields[f.focus].kind != formFieldText { - return nil - } - var cmd tea.Cmd - f.fields[f.focus].input, cmd = f.fields[f.focus].input.Update(msg) - return cmd -} - -func (f *vmForm) change(delta int) { - if len(f.fields) == 0 { - return - } - field := &f.fields[f.focus] - if field.kind != formFieldSelect || len(field.options) == 0 { - return - } - field.index = wrapIndex(field.index+delta, len(field.options)) -} - -func (f *vmForm) view(width int) string { - f.setWidth(width) - titleStyle := lipgloss.NewStyle().Bold(true) - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) - valueStyle := lipgloss.NewStyle().Bold(true) - - lines := []string{titleStyle.Render(f.title), ""} - for i := range f.fields { - marker := " " - lbl := labelStyle.Render(f.fields[i].label) - if i == f.focus { - marker = "> " - lbl = activeStyle.Render(f.fields[i].label) - } - value := "" - if f.fields[i].kind == formFieldText { - value = f.fields[i].input.View() - } else { - value = valueStyle.Render(f.fields[i].value()) - } - lines = append(lines, fmt.Sprintf("%s%-16s %s", marker, lbl, value)) - } - lines = append(lines, "", fmt.Sprintf("Enter %s Esc cancel", strings.ToLower(f.submitLabel))) - - style := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(1, 2). - Width(maxInt(36, width)) - return style.Render(strings.Join(lines, "\n")) -} - -func (f *vmForm) submit() (actionRequest, error) { - switch f.mode { - case actionCreate: - return f.createRequest() - case actionEdit: - return f.editRequest() - default: - return actionRequest{}, errors.New("unsupported form mode") - } -} - -func (f *vmForm) createRequest() (actionRequest, error) { - vcpu, err := parsePositiveInt("vcpu", f.fields[2].value()) - if err != nil { - return actionRequest{}, err - } - memory, err := parsePositiveInt("memory", f.fields[3].value()) - if err != nil { - return actionRequest{}, err - } - params := api.VMCreateParams{ - Name: strings.TrimSpace(f.fields[0].value()), - ImageName: strings.TrimSpace(f.fields[1].value()), - VCPUCount: &vcpu, - MemoryMiB: &memory, - SystemOverlaySize: strings.TrimSpace(f.fields[4].value()), - WorkDiskSize: strings.TrimSpace(f.fields[5].value()), - NATEnabled: isYes(f.fields[6].value()), - NoStart: isYes(f.fields[7].value()), - } - if params.ImageName == "" || params.ImageName == "" { - return actionRequest{}, errors.New("create requires an image") - } - return actionRequest{kind: actionCreate, create: params}, nil -} - -func (f *vmForm) editRequest() (actionRequest, error) { - vcpu, err := parsePositiveInt("vcpu", f.fields[0].value()) - if err != nil { - return actionRequest{}, err - } - memory, err := parsePositiveInt("memory", f.fields[1].value()) - if err != nil { - return actionRequest{}, err - } - params, err := vmSetParamsFromFlags( - f.targetID, - vcpu, - memory, - strings.TrimSpace(f.fields[2].value()), - isYes(f.fields[3].value()), - false, - ) - if err != nil { - return actionRequest{}, err - } - return actionRequest{ - kind: actionEdit, - id: f.targetID, - set: params, - }, nil -} - -type tuiModel struct { - layout paths.Layout - cfg model.DaemonConfig - - width int - height int - ready bool - - loadGeneration int - loading bool - busy string - sudoValidated bool - daemonReady bool - daemonPending bool - vmListPending bool - imagePending bool - imagesLoaded bool - daemonLoadDur time.Duration - vmListDur time.Duration - imageListDur time.Duration - - mode tuiMode - form *vmForm - pendingAction *actionRequest - - vms []model.VMRecord - images []model.Image - selectedID string - selectedStats model.VMStats - statsID string - statsErr string - - table table.Model - detail viewport.Model - help help.Model - spinner spinner.Model - - browseKeys browseKeyMap - formKeys formKeyMap - confirmKeys confirmKeyMap - - lastRefresh time.Time - statusText string - statusErr bool - hostCPUCount int - hostMemoryBytes int64 - hostDiskBytes int64 -} - -func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel { - hostResources, err := system.ReadHostResources() - hostCPUCount := 0 - hostMemoryBytes := int64(0) - hostDiskBytes := int64(0) - if err == nil { - hostCPUCount = hostResources.CPUCount - hostMemoryBytes = hostResources.TotalMemoryBytes - } - if diskUsage, err := readTUIFilesystemUsage(layout); err == nil { - hostDiskBytes = diskUsage.TotalBytes - } - - vmTable := table.New( - table.WithColumns([]table.Column{ - {Title: "NAME", Width: 18}, - {Title: "STATE", Width: 9}, - {Title: "IP", Width: 14}, - {Title: "VCPU", Width: 4}, - {Title: "MEM", Width: 8}, - {Title: "DISK", Width: 8}, - {Title: "AGE", Width: 12}, - }), - table.WithRows(nil), - table.WithFocused(true), - table.WithHeight(10), - table.WithWidth(60), - table.WithKeyMap(tuiTableKeyMap()), - ) - tableStyles := table.DefaultStyles() - tableStyles.Header = lipgloss.NewStyle().Bold(true).Padding(0, 1) - tableStyles.Cell = lipgloss.NewStyle().Padding(0, 1) - tableStyles.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("62")) - vmTable.SetStyles(tableStyles) - - detail := viewport.New(0, 0) - detail.Style = lipgloss.NewStyle() - - spin := spinner.New(spinner.WithSpinner(spinner.Line)) - spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) - - helpView := help.New() - - model := tuiModel{ - layout: layout, - cfg: cfg, - width: 120, - height: 32, - ready: true, - table: vmTable, - detail: detail, - help: helpView, - spinner: spin, - browseKeys: newBrowseKeyMap(), - formKeys: newFormKeyMap(), - confirmKeys: newConfirmKeyMap(), - loadGeneration: 1, - loading: true, - daemonPending: true, - statusText: "Starting daemon...", - hostCPUCount: hostCPUCount, - hostMemoryBytes: hostMemoryBytes, - hostDiskBytes: hostDiskBytes, - } - model.resize() - return model -} - -func newTUICommand() *cobra.Command { - return &cobra.Command{ - Use: "tui", - Short: "Launch a terminal UI to manage VMs", - Args: noArgsUsage("usage: banger tui"), - RunE: func(cmd *cobra.Command, args []string) error { - if !tuiIsTerminal(os.Stdin.Fd()) || !tuiIsTerminal(os.Stdout.Fd()) { - return errors.New("tui requires an interactive terminal") - } - return tuiProgramRunner(newTUIModel(paths.Layout{}, model.DaemonConfig{})) - }, - } -} - -func (m tuiModel) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, refreshTickCmd(), ensureDaemonCmd(m.loadGeneration)) -} - -func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - if m.loading || m.busy != "" { - var spinCmd tea.Cmd - m.spinner, spinCmd = m.spinner.Update(msg) - cmds = append(cmds, spinCmd) - } - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.ready = true - m.width = msg.Width - m.height = msg.Height - m.resize() - case daemonReadyMsg: - if msg.generation != m.loadGeneration { - break - } - m.daemonPending = false - if msg.err != nil { - m.syncLoadingState() - m.setStatus(fmt.Sprintf("starting daemon: %v", msg.err), true) - break - } - m.layout = msg.layout - m.cfg = msg.cfg - m.daemonReady = true - m.daemonLoadDur = msg.duration - if diskUsage, err := readTUIFilesystemUsage(m.layout); err == nil { - m.hostDiskBytes = diskUsage.TotalBytes - } - m.beginListLoad("") - cmds = append(cmds, m.spinner.Tick, fetchVMListCmd(m.layout, "", m.loadGeneration), fetchImageListCmd(m.layout, m.loadGeneration)) - case vmListLoadedMsg: - if msg.generation != m.loadGeneration { - break - } - m.vmListPending = false - if msg.err != nil { - m.syncLoadingState() - m.setStatus(fmt.Sprintf("loading vms: %v", msg.err), true) - break - } - m.vms = msg.vms - m.vmListDur = msg.duration - targetID := m.selectedID - if msg.focusID != "" { - targetID = msg.focusID - } - m.selectedID = resolveSelectedID(targetID, msg.vms) - m.lastRefresh = time.Now() - m.rebuildTable() - m.syncLoadingState() - m.setLoadStatus(false) - if m.selectedID != "" { - cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID)) - } else { - m.selectedStats = model.VMStats{} - m.statsID = "" - m.statsErr = "" - } - case imageListLoadedMsg: - if msg.generation != m.loadGeneration { - break - } - m.imagePending = false - if msg.err != nil { - m.syncLoadingState() - m.setStatus(fmt.Sprintf("loading images: %v", msg.err), true) - break - } - m.images = msg.images - m.imagesLoaded = true - m.imageListDur = msg.duration - m.syncLoadingState() - m.setLoadStatus(false) - case statsLoadedMsg: - if msg.id != m.selectedID { - break - } - if msg.err != nil { - m.statsErr = msg.err.Error() - m.setStatus(msg.err.Error(), true) - break - } - m.selectedStats = msg.stats - m.statsID = msg.id - m.statsErr = "" - case actionResultMsg: - m.busy = "" - if msg.err != nil { - if looksLikeSudoExpiry(msg.err) { - m.sudoValidated = false - } - m.setStatus(msg.err.Error(), true) - break - } - if msg.action.kind == actionCreate || msg.action.kind == actionEdit { - m.form = nil - m.mode = tuiModeBrowse - } - if msg.action.kind == actionDelete { - m.mode = tuiModeBrowse - } - m.setStatus(msg.status, false) - if msg.refresh { - cmds = append(cmds, m.beginRefreshLoad(msg.focusID)...) - } - case externalPreparedMsg: - if msg.err != nil { - m.setStatus(msg.err.Error(), true) - break - } - cmds = append(cmds, tea.ExecProcess(msg.command, func(err error) tea.Msg { - err = normalizeExecError(err) - if msg.done != nil { - return msg.done(err) - } - return actionResultMsg{ - action: msg.action, - status: msg.doneStatus, - err: err, - refresh: msg.refresh, - focusID: m.selectedID, - } - })) - case sudoValidatedMsg: - if msg.err != nil { - m.pendingAction = nil - m.busy = "" - m.setStatus(msg.err.Error(), true) - break - } - m.sudoValidated = true - if m.pendingAction != nil { - action := *m.pendingAction - m.pendingAction = nil - m.busy = action.activity() - cmds = append(cmds, m.spinner.Tick, m.runActionCmd(action)) - } - case refreshTickMsg: - cmds = append(cmds, refreshTickCmd()) - if m.busy == "" && m.mode == tuiModeBrowse && m.daemonReady && !m.vmListPending && !m.imagePending { - cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...) - } - case tea.KeyMsg: - switch m.mode { - case tuiModeBrowse: - nextModel, extraCmds := m.updateBrowse(msg) - m = nextModel - cmds = append(cmds, extraCmds...) - case tuiModeForm: - nextModel, extraCmds := m.updateForm(msg) - m = nextModel - cmds = append(cmds, extraCmds...) - case tuiModeConfirmDelete: - nextModel, extraCmds := m.updateConfirmDelete(msg) - m = nextModel - cmds = append(cmds, extraCmds...) - } - } - - m.refreshDetail() - return m, tea.Batch(cmds...) -} - -func (m tuiModel) updateBrowse(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { - var cmds []tea.Cmd - switch { - case key.Matches(msg, m.browseKeys.quit): - cmds = append(cmds, tea.Quit) - case key.Matches(msg, m.browseKeys.help): - m.help.ShowAll = !m.help.ShowAll - case key.Matches(msg, m.browseKeys.refresh): - if !m.daemonReady { - cmds = append(cmds, m.beginBootstrapLoad()...) - } else { - cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...) - } - case key.Matches(msg, m.browseKeys.create): - if !m.imagesLoaded { - m.setStatus("Images are still loading", true) - return m, cmds - } - if len(m.images) == 0 { - m.setStatus("Create requires at least one image", true) - return m, cmds - } - m.form = newCreateVMForm(m.images, m.cfg) - m.mode = tuiModeForm - cmds = append(cmds, m.form.focusField(m.form.focus)) - case key.Matches(msg, m.browseKeys.edit): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - if vm.State == model.VMStateRunning { - m.setStatus("Stop the VM before editing it", true) - return m, cmds - } - m.form = newEditVMForm(vm) - m.mode = tuiModeForm - cmds = append(cmds, m.form.focusField(m.form.focus)) - case key.Matches(msg, m.browseKeys.delete): - if _, ok := m.selectedVM(); !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - m.mode = tuiModeConfirmDelete - case key.Matches(msg, m.browseKeys.start): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - if vm.State == model.VMStateRunning { - m.setStatus("VM is already running", true) - return m, cmds - } - cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStart, id: vm.ID, name: vm.Name})) - case key.Matches(msg, m.browseKeys.stop): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - if vm.State != model.VMStateRunning { - m.setStatus("VM is not running", true) - return m, cmds - } - cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStop, id: vm.ID, name: vm.Name})) - case key.Matches(msg, m.browseKeys.restart): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionRestart, id: vm.ID, name: vm.Name})) - case key.Matches(msg, m.browseKeys.ssh): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - if vm.State != model.VMStateRunning { - m.setStatus("SSH requires a running VM", true) - return m, cmds - } - cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionSSH, id: vm.ID, name: vm.Name})) - case key.Matches(msg, m.browseKeys.logs): - vm, ok := m.selectedVM() - if !ok { - m.setStatus("No VM selected", true) - return m, cmds - } - cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionLogs, id: vm.ID, name: vm.Name})) - default: - if len(m.vms) == 0 { - return m, cmds - } - oldCursor := m.table.Cursor() - var tableCmd tea.Cmd - m.table, tableCmd = m.table.Update(msg) - cmds = append(cmds, tableCmd) - if m.table.Cursor() != oldCursor { - if vm, ok := m.selectedVMByCursor(); ok { - m.selectedID = vm.ID - m.selectedStats = model.VMStats{} - m.statsID = "" - m.statsErr = "" - cmds = append(cmds, fetchStatsCmd(m.layout, vm.ID)) - } - } - } - return m, cmds -} - -func (m tuiModel) updateForm(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { - var cmds []tea.Cmd - switch { - case key.Matches(msg, m.formKeys.cancel): - m.form = nil - m.mode = tuiModeBrowse - case key.Matches(msg, m.formKeys.next): - cmds = append(cmds, m.form.move(1)) - case key.Matches(msg, m.formKeys.prev): - cmds = append(cmds, m.form.move(-1)) - case key.Matches(msg, m.formKeys.change): - switch msg.String() { - case "left": - m.form.change(-1) - case "right": - m.form.change(1) - } - case key.Matches(msg, m.formKeys.toggle): - m.form.change(1) - case key.Matches(msg, m.formKeys.submit): - action, err := m.form.submit() - if err != nil { - m.setStatus(err.Error(), true) - return m, cmds - } - cmds = append(cmds, m.dispatchAction(action)) - default: - cmds = append(cmds, m.form.update(msg)) - } - return m, cmds -} - -func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) { - switch { - case key.Matches(msg, m.confirmKeys.cancel): - m.mode = tuiModeBrowse - return m, nil - case key.Matches(msg, m.confirmKeys.confirm): - vm, ok := m.selectedVM() - if !ok { - m.mode = tuiModeBrowse - m.setStatus("No VM selected", true) - return m, nil - } - return m, []tea.Cmd{m.dispatchAction(actionRequest{kind: actionDelete, id: vm.ID, name: vm.Name})} - default: - return m, nil - } -} - -func (m tuiModel) View() string { - header := m.renderHeader() - resourceBar := m.renderResourceBar() - body := m.renderBody() - status := m.renderStatus() - m.help.Width = m.width - helpView := m.help.View(m.currentKeyMap()) - return lipgloss.JoinVertical(lipgloss.Left, header, resourceBar, body, status, helpView) -} - -func (m tuiModel) currentKeyMap() help.KeyMap { - switch m.mode { - case tuiModeForm: - return m.formKeys - case tuiModeConfirmDelete: - return m.confirmKeys - default: - return m.browseKeys - } -} - -func (m *tuiModel) resize() { - bodyHeight := m.bodyHeight() - leftWidth := maxInt(42, (m.width*55)/100) - if leftWidth > m.width-24 { - leftWidth = maxInt(24, m.width/2) - } - rightWidth := maxInt(24, m.width-leftWidth-1) - - leftInnerWidth := maxInt(20, leftWidth-4) - rightInnerWidth := maxInt(20, rightWidth-4) - panelInnerHeight := maxInt(8, bodyHeight-2) - - m.table.SetWidth(leftInnerWidth) - m.table.SetHeight(maxInt(4, panelInnerHeight-2)) - m.detail.Width = rightInnerWidth - m.detail.Height = maxInt(4, panelInnerHeight-2) - if m.form != nil { - m.form.setWidth(maxInt(28, minInt(58, m.width-10))) - } -} - -func (m tuiModel) bodyHeight() int { - return maxInt(8, m.height-5) -} - -func (m tuiModel) renderHeader() string { - status := "idle" - if m.busy != "" { - status = m.spinner.View() + " " + m.busy - } else if phase := m.loadingPhase(); phase != "" { - status = m.spinner.View() + " " + phase - } - refreshed := "never" - if !m.lastRefresh.IsZero() { - refreshed = relativeTime(m.lastRefresh) - } - socketLabel := filepath.Base(m.layout.SocketPath) - if socketLabel == "." || socketLabel == "" { - socketLabel = "pending" - } - header := fmt.Sprintf("banger tui socket %s %s last refresh %s", socketLabel, status, refreshed) - return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header) -} - -func (m tuiModel) renderResourceBar() string { - runningVMs, totalVCPUs, totalMemoryBytes := aggregateRunningVMResources(m.vms) - totalDiskBytes := aggregateVMDiskUsage(m.vms) - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - runningStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true) - - parts := []string{ - labelStyle.Render("VMs") + " " + runningStyle.Render(fmt.Sprintf("%d/%d", runningVMs, len(m.vms))), - renderUsageMeter("CPU", int64(totalVCPUs), int64(m.hostCPUCount), strconv.Itoa(totalVCPUs), totalLabel(m.hostCPUCount)), - renderUsageMeter("RAM", totalMemoryBytes, m.hostMemoryBytes, formatBytes(totalMemoryBytes), bytesTotalLabel(m.hostMemoryBytes)), - renderUsageMeter("Disk", totalDiskBytes, m.hostDiskBytes, formatBytes(totalDiskBytes), bytesTotalLabel(m.hostDiskBytes)), - } - - return lipgloss.NewStyle(). - Width(m.width). - Render(lipgloss.JoinHorizontal(lipgloss.Left, parts...)) -} - -func (m tuiModel) renderBody() string { - bodyHeight := m.bodyHeight() - if m.mode == tuiModeForm && m.form != nil { - return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.form.view(minInt(58, m.width-10))) - } - if m.mode == tuiModeConfirmDelete { - return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.confirmDeleteView()) - } - - leftWidth := maxInt(42, (m.width*55)/100) - if leftWidth > m.width-24 { - leftWidth = maxInt(24, m.width/2) - } - rightWidth := maxInt(24, m.width-leftWidth-1) - leftInnerWidth := maxInt(20, leftWidth-4) - rightInnerWidth := maxInt(20, rightWidth-4) - panelHeight := maxInt(8, bodyHeight-1) - - leftContent := m.table.View() - if len(m.vms) == 0 { - leftContent = m.vmListPlaceholder() - } - leftPanel := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(0, 1). - Width(leftWidth). - Height(panelHeight). - Render(lipgloss.NewStyle().Width(leftInnerWidth).Render(leftContent)) - - rightPanel := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(0, 1). - Width(rightWidth). - Height(panelHeight). - Render(lipgloss.NewStyle().Width(rightInnerWidth).Render(m.detail.View())) - - return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) -} - -func (m tuiModel) confirmDeleteView() string { - vm, ok := m.selectedVM() - name := "this VM" - if ok { - name = vm.Name - } - content := fmt.Sprintf("Delete %s?\n\nThis removes the VM and its persistent state.\n\nEnter confirm Esc cancel", name) - return lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(1, 2). - Width(54). - Render(content) -} - -func (m tuiModel) renderStatus() string { - if m.statusText == "" { - return " " - } - style := lipgloss.NewStyle().Foreground(lipgloss.Color("70")) - if m.statusErr { - style = lipgloss.NewStyle().Foreground(lipgloss.Color("160")) - } - return style.Width(m.width).Render(m.statusText) -} - -func (m *tuiModel) rebuildTable() { - rows := make([]table.Row, 0, len(m.vms)) - cursor := 0 - for i, vm := range m.vms { - rows = append(rows, table.Row{ - vm.Name, - string(vm.State), - vm.Runtime.GuestIP, - strconv.Itoa(vm.Spec.VCPUCount), - fmt.Sprintf("%dM", vm.Spec.MemoryMiB), - model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), - relativeTime(vm.CreatedAt), - }) - if vm.ID == m.selectedID { - cursor = i - } - } - m.table.SetRows(rows) - if len(rows) > 0 { - m.table.SetCursor(cursor) - } -} - -func (m *tuiModel) refreshDetail() { - vm, ok := m.selectedVM() - if !ok { - m.detail.SetContent(m.detailPlaceholder()) - return - } - stats := vm.Stats - if m.statsID == vm.ID && !m.selectedStats.CollectedAt.IsZero() { - stats = m.selectedStats - } - lines := []string{ - fmt.Sprintf("Name: %s", vm.Name), - fmt.Sprintf("State: %s", vm.State), - fmt.Sprintf("IP: %s", orDash(vm.Runtime.GuestIP)), - fmt.Sprintf("DNS: %s", orDash(vm.Runtime.DNSName)), - fmt.Sprintf("Image: %s", shortID(vm.ImageID)), - "", - "Config", - fmt.Sprintf(" vCPU: %d", vm.Spec.VCPUCount), - fmt.Sprintf(" Memory: %d MiB", vm.Spec.MemoryMiB), - fmt.Sprintf(" Overlay: %s", model.FormatSizeBytes(vm.Spec.SystemOverlaySizeByte)), - fmt.Sprintf(" Work disk: %s", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)), - fmt.Sprintf(" NAT: %s", yesNo(vm.Spec.NATEnabled)), - "", - "Usage", - fmt.Sprintf(" Overlay: %s", formatBytes(stats.SystemOverlayBytes)), - fmt.Sprintf(" Work disk: %s", formatBytes(stats.WorkDiskBytes)), - fmt.Sprintf(" CPU: %.1f%%", stats.CPUPercent), - fmt.Sprintf(" RSS: %s", formatBytes(stats.RSSBytes)), - fmt.Sprintf(" VSZ: %s", formatBytes(stats.VSZBytes)), - } - if !stats.CollectedAt.IsZero() { - lines = append(lines, fmt.Sprintf(" Updated: %s", stats.CollectedAt.Format(time.RFC3339))) - } - lines = append(lines, - "", - "Timestamps", - fmt.Sprintf(" Created: %s", vm.CreatedAt.Format(time.RFC3339)), - fmt.Sprintf(" Updated: %s", vm.UpdatedAt.Format(time.RFC3339)), - fmt.Sprintf(" Touched: %s", vm.LastTouchedAt.Format(time.RFC3339)), - ) - if vm.Runtime.LastError != "" { - lines = append(lines, "", "Last error", " "+vm.Runtime.LastError) - } - if m.statsErr != "" && m.statsID == vm.ID { - lines = append(lines, "", "Stats error", " "+m.statsErr) - } - m.detail.SetContent(strings.Join(lines, "\n")) -} - -func (m *tuiModel) setStatus(text string, isErr bool) { - m.statusText = text - m.statusErr = isErr -} - -func (m *tuiModel) beginBootstrapLoad() []tea.Cmd { - m.loadGeneration++ - m.daemonReady = false - m.daemonPending = true - m.vmListPending = false - m.imagePending = false - m.imagesLoaded = false - m.images = nil - m.daemonLoadDur = 0 - m.vmListDur = 0 - m.imageListDur = 0 - m.syncLoadingState() - m.setLoadStatus(false) - return []tea.Cmd{m.spinner.Tick, ensureDaemonCmd(m.loadGeneration)} -} - -func (m *tuiModel) beginListLoad(focusID string) { - m.vmListPending = true - m.imagePending = true - if len(m.images) == 0 { - m.imagesLoaded = false - } - m.vmListDur = 0 - m.imageListDur = 0 - m.syncLoadingState() - m.setLoadStatus(false) - if focusID == "" { - return - } - m.selectedID = focusID -} - -func (m *tuiModel) beginRefreshLoad(focusID string) []tea.Cmd { - if !m.daemonReady { - return m.beginBootstrapLoad() - } - m.loadGeneration++ - m.beginListLoad(focusID) - return []tea.Cmd{ - m.spinner.Tick, - fetchVMListCmd(m.layout, focusID, m.loadGeneration), - fetchImageListCmd(m.layout, m.loadGeneration), - } -} - -func (m *tuiModel) syncLoadingState() { - m.loading = m.daemonPending || m.vmListPending || m.imagePending -} - -func (m tuiModel) loadingPhase() string { - switch { - case m.daemonPending: - return "starting daemon" - case m.vmListPending && m.imagePending: - return "loading vms and images" - case m.vmListPending: - return "loading vms" - case m.imagePending: - return "loading images" - default: - return "" - } -} - -func (m *tuiModel) setLoadStatus(isErr bool) { - if phase := m.loadingPhase(); phase != "" { - durations := m.stageDurations() - switch phase { - case "loading images": - prefix := fmt.Sprintf("Loaded %d VM(s); loading images", len(m.vms)) - if durations != "" { - m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr) - return - } - m.setStatus(prefix+"...", isErr) - return - case "loading vms": - prefix := fmt.Sprintf("Loaded %d image(s); loading vms", len(m.images)) - if durations != "" { - m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr) - return - } - m.setStatus(prefix+"...", isErr) - return - } - if durations != "" { - m.setStatus(fmt.Sprintf("%s (%s)", capitalizePhase(phase), durations), isErr) - return - } - m.setStatus(capitalizePhase(phase)+"...", isErr) - return - } - if m.daemonReady && m.vmListDur > 0 && m.imageListDur > 0 { - m.setStatus(fmt.Sprintf("Loaded %d VM(s) (%s)", len(m.vms), m.stageDurations()), isErr) - } -} - -func (m tuiModel) stageDurations() string { - parts := make([]string, 0, 3) - if m.daemonLoadDur > 0 { - parts = append(parts, "daemon "+formatTUIDuration(m.daemonLoadDur)) - } - if m.vmListDur > 0 { - parts = append(parts, "vm list "+formatTUIDuration(m.vmListDur)) - } - if m.imageListDur > 0 { - parts = append(parts, "image list "+formatTUIDuration(m.imageListDur)) - } - return strings.Join(parts, ", ") -} - -func (m tuiModel) vmListPlaceholder() string { - switch { - case m.daemonPending: - return "Starting daemon...\n\nWaiting for bangerd to become ready." - case m.vmListPending: - return "Loading VMs..." - default: - return "No VMs.\n\nPress c to create one." - } -} - -func (m tuiModel) detailPlaceholder() string { - switch { - case m.daemonPending: - return "Starting daemon...\n\nThe TUI will populate once bangerd is ready." - case m.vmListPending: - return "Loading VMs..." - case len(m.vms) == 0: - if m.imagePending { - return "No VM selected.\n\nImages are still loading." - } - return "No VM selected.\n\nUse c to create a VM." - default: - return "No VM selected." - } -} - -func ensureDaemonCmd(generation int) tea.Cmd { - return func() tea.Msg { - start := time.Now() - layout, cfg, err := tuiEnsureDaemonFunc(context.Background()) - return daemonReadyMsg{ - generation: generation, - layout: layout, - cfg: cfg, - duration: time.Since(start), - err: err, - } - } -} - -func (m tuiModel) selectedVM() (model.VMRecord, bool) { - for _, vm := range m.vms { - if vm.ID == m.selectedID { - return vm, true - } - } - return model.VMRecord{}, false -} - -func (m tuiModel) selectedVMByCursor() (model.VMRecord, bool) { - cursor := m.table.Cursor() - if cursor < 0 || cursor >= len(m.vms) { - return model.VMRecord{}, false - } - return m.vms[cursor], true -} - -func (m *tuiModel) dispatchAction(action actionRequest) tea.Cmd { - if action.needsSudo() && !m.sudoValidated { - m.pendingAction = &action - m.busy = "Authorizing sudo..." - return tea.ExecProcess(exec.Command("sudo", "-v"), func(err error) tea.Msg { - return sudoValidatedMsg{err: err} - }) - } - m.busy = action.activity() - return tea.Batch(m.spinner.Tick, m.runActionCmd(action)) -} - -func (m tuiModel) runActionCmd(action actionRequest) tea.Cmd { - switch action.kind { - case actionCreate: - return createActionCmd(m.layout, action) - case actionEdit: - return editActionCmd(m.layout, action) - case actionStart, actionStop, actionRestart: - return lifecycleActionCmd(m.layout, action) - case actionDelete: - return deleteActionCmd(m.layout, action) - case actionSSH: - return prepareSSHCmd(m.layout, m.cfg, action) - case actionLogs: - return prepareLogsCmd(m.layout, action) - default: - return func() tea.Msg { - return actionResultMsg{action: action, err: fmt.Errorf("unsupported action %s", action.kind)} - } - } -} - -func createActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { - return func() tea.Msg { - result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.create", action.create) - if err != nil { - return actionResultMsg{action: action, err: err} - } - return actionResultMsg{ - action: action, - focusID: result.VM.ID, - status: fmt.Sprintf("created %s", result.VM.Name), - refresh: true, - } - } -} - -func editActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { - return func() tea.Msg { - result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.set", action.set) - if err != nil { - return actionResultMsg{action: action, err: err} - } - return actionResultMsg{ - action: action, - focusID: result.VM.ID, - status: fmt.Sprintf("updated %s", result.VM.Name), - refresh: true, - } - } -} - -func lifecycleActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { - method := "" - status := "" - switch action.kind { - case actionStart: - method = "vm.start" - status = "started" - case actionStop: - method = "vm.stop" - status = "stopped" - case actionRestart: - method = "vm.restart" - status = "restarted" - } - return func() tea.Msg { - result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, method, api.VMRefParams{IDOrName: action.id}) - if err != nil { - return actionResultMsg{action: action, err: err} - } - return actionResultMsg{ - action: action, - focusID: result.VM.ID, - status: fmt.Sprintf("%s %s", status, result.VM.Name), - refresh: true, - } - } -} - -func deleteActionCmd(layout paths.Layout, action actionRequest) tea.Cmd { - return func() tea.Msg { - _, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.delete", api.VMRefParams{IDOrName: action.id}) - if err != nil { - return actionResultMsg{action: action, err: err} - } - return actionResultMsg{ - action: action, - status: fmt.Sprintf("deleted %s", action.name), - refresh: true, - } - } -} - -func prepareSSHCmd(layout paths.Layout, cfg model.DaemonConfig, action actionRequest) tea.Cmd { - return func() tea.Msg { - if err := validateSSHPrereqs(cfg); err != nil { - return externalPreparedMsg{action: action, err: err} - } - result, err := rpc.Call[api.VMSSHResult](context.Background(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: action.id}) - if err != nil { - return externalPreparedMsg{action: action, err: err} - } - args, err := sshCommandArgs(cfg, result.GuestIP, nil) - if err != nil { - return externalPreparedMsg{action: action, err: err} - } - return externalPreparedMsg{ - action: action, - command: exec.Command("ssh", args...), - done: func(execErr error) tea.Msg { - return sshDoneMsg(layout, action, result.Name, execErr) - }, - refresh: true, - } - } -} - -func sshDoneMsg(layout paths.Layout, action actionRequest, name string, execErr error) tea.Msg { - if execErr != nil { - return actionResultMsg{ - action: action, - err: execErr, - refresh: true, - focusID: action.id, - } - } - pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - health, err := vmHealthFunc(pingCtx, layout.SocketPath, name) - if err != nil { - return actionResultMsg{ - action: action, - status: vsockagent.WarningMessage(name, err), - refresh: true, - focusID: action.id, - } - } - if health.Healthy { - if strings.TrimSpace(health.Name) != "" { - name = health.Name - } - return actionResultMsg{ - action: action, - status: vsockagent.ReminderMessage(name), - refresh: true, - focusID: action.id, - } - } - return actionResultMsg{ - action: action, - status: fmt.Sprintf("ssh session ended for %s", name), - refresh: true, - focusID: action.id, - } -} - -func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd { - return func() tea.Msg { - result, err := rpc.Call[api.VMLogsResult](context.Background(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: action.id}) - if err != nil { - return externalPreparedMsg{action: action, err: err} - } - if result.LogPath == "" { - return externalPreparedMsg{action: action, err: errors.New("vm has no log path")} - } - return externalPreparedMsg{ - action: action, - command: system.TailCommand(result.LogPath, true), - doneStatus: fmt.Sprintf("closed log view for %s", action.name), - refresh: false, - } - } -} - -func fetchVMListCmd(layout paths.Layout, focusID string, generation int) tea.Cmd { - return func() tea.Msg { - start := time.Now() - vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{}) - if err != nil { - return vmListLoadedMsg{generation: generation, err: err, focusID: focusID} - } - return vmListLoadedMsg{ - generation: generation, - vms: vms.VMs, - focusID: focusID, - duration: time.Since(start), - } - } -} - -func fetchImageListCmd(layout paths.Layout, generation int) tea.Cmd { - return func() tea.Msg { - start := time.Now() - images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{}) - if err != nil { - return imageListLoadedMsg{generation: generation, err: err} - } - return imageListLoadedMsg{ - generation: generation, - images: images.Images, - duration: time.Since(start), - } - } -} - -func fetchStatsCmd(layout paths.Layout, id string) tea.Cmd { - return func() tea.Msg { - result, err := rpc.Call[api.VMStatsResult](context.Background(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: id}) - if err != nil { - return statsLoadedMsg{id: id, err: err} - } - return statsLoadedMsg{id: id, stats: result.Stats} - } -} - -func refreshTickCmd() tea.Cmd { - return tea.Tick(tuiRefreshInterval, func(time.Time) tea.Msg { - return refreshTickMsg{} - }) -} - -func resolveSelectedID(targetID string, vms []model.VMRecord) string { - if len(vms) == 0 { - return "" - } - if targetID != "" { - for _, vm := range vms { - if vm.ID == targetID { - return targetID - } - } - } - return vms[0].ID -} - -func capitalizePhase(value string) string { - if value == "" { - return "" - } - return strings.ToUpper(value[:1]) + value[1:] -} - -func formatTUIDuration(value time.Duration) string { - switch { - case value >= time.Second: - return value.Round(100 * time.Millisecond).String() - case value >= 100*time.Millisecond: - return value.Round(10 * time.Millisecond).String() - default: - return value.Round(time.Millisecond).String() - } -} - -func aggregateRunningVMResources(vms []model.VMRecord) (runningCount, totalVCPUs int, totalMemoryBytes int64) { - for _, vm := range vms { - if vm.State != model.VMStateRunning { - continue - } - runningCount++ - totalVCPUs += vm.Spec.VCPUCount - totalMemoryBytes += int64(vm.Spec.MemoryMiB) * 1024 * 1024 - } - return runningCount, totalVCPUs, totalMemoryBytes -} - -func aggregateVMDiskUsage(vms []model.VMRecord) int64 { - var total int64 - for _, vm := range vms { - total += system.AllocatedBytes(vm.Runtime.SystemOverlay) - total += system.AllocatedBytes(vm.Runtime.WorkDiskPath) - } - return total -} - -func renderUsageMeter(label string, used, total int64, usedText, totalText string) string { - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - valueStyle := lipgloss.NewStyle().Bold(true) - bar := renderProgressBar(used, total, 12) - return fmt.Sprintf(" %s %s %s", labelStyle.Render(label), bar, valueStyle.Render(usedText+"/"+totalText)) -} - -func renderProgressBar(used, total int64, width int) string { - if width <= 0 { - return "" - } - if total <= 0 { - unknownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - return unknownStyle.Render("[" + strings.Repeat("?", width) + "]") - } - ratio := float64(used) / float64(total) - if ratio < 0 { - ratio = 0 - } - if ratio > 1 { - ratio = 1 - } - filled := int(ratio * float64(width)) - if used > 0 && filled == 0 { - filled = 1 - } - if filled > width { - filled = width - } - empty := width - filled - - barColor := lipgloss.Color("70") - switch { - case ratio >= 0.9: - barColor = lipgloss.Color("160") - case ratio >= 0.75: - barColor = lipgloss.Color("214") - } - - filledStyle := lipgloss.NewStyle().Foreground(barColor) - emptyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) - return "[" + filledStyle.Render(strings.Repeat("█", filled)) + emptyStyle.Render(strings.Repeat("░", empty)) + "]" -} - -func totalLabel(total int) string { - if total <= 0 { - return "-" - } - return strconv.Itoa(total) -} - -func bytesTotalLabel(total int64) string { - if total <= 0 { - return "-" - } - return formatBytes(total) -} - -func readTUIFilesystemUsage(layout paths.Layout) (system.FilesystemUsage, error) { - target := strings.TrimSpace(layout.StateDir) - if target == "" { - resolved, err := paths.Resolve() - if err != nil { - return system.FilesystemUsage{}, err - } - target = resolved.StateDir - } - return system.ReadFilesystemUsage(target) -} - -func imageNames(images []model.Image) []string { - names := make([]string, 0, len(images)) - for _, image := range images { - names = append(names, image.Name) - } - return names -} - -func tuiTableKeyMap() table.KeyMap { - return table.KeyMap{ - LineUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up", "up")), - LineDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down", "down")), - PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), - PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), - HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "half up")), - HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "half down")), - GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "top")), - GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "bottom")), - } -} - -func (a actionRequest) needsSudo() bool { - switch a.kind { - case actionCreate, actionEdit, actionStart, actionStop, actionRestart, actionDelete: - return true - default: - return false - } -} - -func (a actionRequest) activity() string { - switch a.kind { - case actionCreate: - return "Creating VM..." - case actionEdit: - return "Saving VM..." - case actionStart: - return "Starting VM..." - case actionStop: - return "Stopping VM..." - case actionRestart: - return "Restarting VM..." - case actionDelete: - return "Deleting VM..." - case actionSSH: - return "Opening SSH..." - case actionLogs: - return "Opening logs..." - default: - return "Working..." - } -} - -func parsePositiveInt(label, raw string) (int, error) { - value, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || value <= 0 { - return 0, fmt.Errorf("%s must be a positive integer", label) - } - return value, nil -} - -func normalizeExecError(err error) error { - if err == nil { - return nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 130 { - return nil - } - return err -} - -func looksLikeSudoExpiry(err error) bool { - if err == nil { - return false - } - text := err.Error() - return strings.Contains(text, "sudo") || strings.Contains(text, "password is required") -} - -func formatBytes(bytes int64) string { - if bytes <= 0 { - return "0" - } - const ( - kib = 1024 - mib = 1024 * kib - gib = 1024 * mib - ) - switch { - case bytes >= gib: - return fmt.Sprintf("%.1fG", float64(bytes)/gib) - case bytes >= mib: - return fmt.Sprintf("%.1fM", float64(bytes)/mib) - case bytes >= kib: - return fmt.Sprintf("%.1fK", float64(bytes)/kib) - default: - return strconv.FormatInt(bytes, 10) + "B" - } -} - -func boolToIndex(value bool) int { - if value { - return 1 - } - return 0 -} - -func yesNo(value bool) string { - if value { - return "yes" - } - return "no" -} - -func isYes(value string) bool { - return strings.EqualFold(strings.TrimSpace(value), "yes") -} - -func orDash(value string) string { - if strings.TrimSpace(value) == "" { - return "-" - } - return value -} - -func wrapIndex(value, length int) int { - if length <= 0 { - return 0 - } - for value < 0 { - value += length - } - return value % length -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go deleted file mode 100644 index acdb078..0000000 --- a/internal/cli/tui_test.go +++ /dev/null @@ -1,396 +0,0 @@ -package cli - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "banger/internal/api" - "banger/internal/model" - "banger/internal/paths" - - tea "github.com/charmbracelet/bubbletea" -) - -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 == nil || *action.create.VCPUCount != 4 || action.create.MemoryMiB == nil || *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) - } -} - -func TestNewTUICommandStartsProgramWithoutEnsuringDaemon(t *testing.T) { - origEnsure := tuiEnsureDaemonFunc - origRunner := tuiProgramRunner - origTerminal := tuiIsTerminal - t.Cleanup(func() { - tuiEnsureDaemonFunc = origEnsure - tuiProgramRunner = origRunner - tuiIsTerminal = origTerminal - }) - - ensureCalled := false - tuiEnsureDaemonFunc = func(ctx context.Context) (paths.Layout, model.DaemonConfig, error) { - ensureCalled = true - return paths.Layout{}, model.DaemonConfig{}, nil - } - tuiProgramRunner = func(model tuiModel) error { - if ensureCalled { - t.Fatal("ensureDaemon should not run before the TUI starts") - } - if !model.daemonPending || !model.loading { - t.Fatalf("startup model = %+v, want pending daemon startup", model) - } - return nil - } - tuiIsTerminal = func(fd uintptr) bool { return true } - - cmd := NewBangerCommand() - cmd.SetArgs([]string{"tui"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - if ensureCalled { - t.Fatal("ensureDaemon should not have been called") - } -} - -func TestTUIViewRendersLayoutImmediately(t *testing.T) { - m := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - view := m.View() - if strings.Contains(view, "Loading...") { - t.Fatalf("view = %q, want full layout instead of one-line loading", view) - } - if !strings.Contains(view, "Starting daemon") { - t.Fatalf("view = %q, want startup placeholder", view) - } -} - -func TestTUIVMLoadCanCompleteBeforeImages(t *testing.T) { - now := time.Date(2026, time.March, 18, 12, 0, 0, 0, time.UTC) - initial := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - - updated, _ := initial.Update(daemonReadyMsg{ - generation: initial.loadGeneration, - layout: paths.Layout{SocketPath: "/tmp/bangerd.sock"}, - cfg: model.DaemonConfig{DefaultImageName: "default"}, - duration: 2400 * time.Millisecond, - }) - m := updated.(tuiModel) - if !m.daemonReady || !m.vmListPending || !m.imagePending { - t.Fatalf("model after daemonReady = %+v, want pending vm/image loads", m) - } - - vm := model.VMRecord{ - ID: "vm-1", - Name: "devbox", - State: model.VMStateRunning, - CreatedAt: now, - UpdatedAt: now, - LastTouchedAt: now, - Spec: model.VMSpec{ - VCPUCount: 2, - MemoryMiB: 1024, - WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024, - }, - Runtime: model.VMRuntime{ - GuestIP: "172.16.0.2", - DNSName: "devbox.vm", - }, - } - updated, _ = m.Update(vmListLoadedMsg{ - generation: m.loadGeneration, - vms: []model.VMRecord{vm}, - duration: 20 * time.Millisecond, - }) - m = updated.(tuiModel) - if len(m.vms) != 1 || m.selectedID != vm.ID { - t.Fatalf("model after vmListLoaded = %+v, want selected vm", m) - } - if !m.imagePending { - t.Fatalf("image load should still be pending: %+v", m) - } - if strings.Contains(m.View(), "No VMs") { - t.Fatalf("view should render the loaded VM while images are pending: %q", m.View()) - } - if !strings.Contains(m.View(), "devbox") { - t.Fatalf("view = %q, want loaded VM name", m.View()) - } -} - -func TestTUICreateBlockedWhileImagesLoad(t *testing.T) { - m := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - m.daemonPending = false - m.daemonReady = true - m.imagePending = true - m.loading = true - - updated, _ := m.updateBrowse(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) - if updated.mode != tuiModeBrowse { - t.Fatalf("mode = %v, want browse", updated.mode) - } - if updated.statusText != "Images are still loading" { - t.Fatalf("status = %q, want image loading warning", updated.statusText) - } -} - -func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) { - initial := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - updated, _ := initial.Update(daemonReadyMsg{ - generation: initial.loadGeneration, - layout: paths.Layout{SocketPath: "/tmp/bangerd.sock"}, - duration: 2400 * time.Millisecond, - }) - m := updated.(tuiModel) - updated, _ = m.Update(vmListLoadedMsg{ - generation: m.loadGeneration, - vms: []model.VMRecord{}, - duration: 20 * time.Millisecond, - }) - m = updated.(tuiModel) - updated, _ = m.Update(imageListLoadedMsg{ - generation: m.loadGeneration, - images: []model.Image{{Name: "default"}}, - duration: 15 * time.Millisecond, - }) - m = updated.(tuiModel) - if !strings.Contains(m.statusText, "daemon 2.4s") || !strings.Contains(m.statusText, "vm list 20ms") || !strings.Contains(m.statusText, "image list 15ms") { - t.Fatalf("statusText = %q, want stage timings", m.statusText) - } -} - -func TestSSHDoneMsgShowsReminderWhenHealthCheckPasses(t *testing.T) { - origHealth := vmHealthFunc - t.Cleanup(func() { - vmHealthFunc = origHealth - }) - vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { - return api.VMHealthResult{Name: "devbox", Healthy: true}, nil - } - - msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) - result, ok := msg.(actionResultMsg) - if !ok { - t.Fatalf("msg = %T, want actionResultMsg", msg) - } - if !strings.Contains(result.status, "devbox is still running") { - t.Fatalf("status = %q, want reminder", result.status) - } -} - -func TestSSHDoneMsgShowsWarningWhenHealthCheckFails(t *testing.T) { - origHealth := vmHealthFunc - t.Cleanup(func() { - vmHealthFunc = origHealth - }) - vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { - return api.VMHealthResult{}, errors.New("dial failed") - } - - msg := sshDoneMsg(paths.Layout{SocketPath: "/tmp/bangerd.sock"}, actionRequest{id: "devbox", name: "devbox"}, "devbox", nil) - result := msg.(actionResultMsg) - if !strings.Contains(result.status, "failed to check whether devbox is still running") { - t.Fatalf("status = %q, want warning", result.status) - } -} - -func TestAggregateRunningVMResources(t *testing.T) { - t.Parallel() - - running, vcpus, memoryBytes := aggregateRunningVMResources([]model.VMRecord{ - { - State: model.VMStateRunning, - Spec: model.VMSpec{ - VCPUCount: 2, - MemoryMiB: 1024, - }, - }, - { - State: model.VMStateStopped, - Spec: model.VMSpec{ - VCPUCount: 8, - MemoryMiB: 8192, - }, - }, - { - State: model.VMStateRunning, - Spec: model.VMSpec{ - VCPUCount: 4, - MemoryMiB: 2048, - }, - }, - }) - - if running != 2 || vcpus != 6 || memoryBytes != 3*1024*1024*1024 { - t.Fatalf("aggregateRunningVMResources = (%d, %d, %d), want (2, 6, %d)", running, vcpus, memoryBytes, int64(3*1024*1024*1024)) - } -} - -func TestTUIViewShowsResourceBar(t *testing.T) { - t.Parallel() - - m := newTUIModel(paths.Layout{}, model.DaemonConfig{}) - m.hostCPUCount = 32 - m.hostMemoryBytes = 125 * 1024 * 1024 * 1024 - m.hostDiskBytes = 200 * 1024 * 1024 * 1024 - m.daemonPending = false - m.loading = false - stateDir := t.TempDir() - overlayPath := filepath.Join(stateDir, "system.cow") - workDiskPath := filepath.Join(stateDir, "root.ext4") - if err := os.WriteFile(overlayPath, make([]byte, 1024), 0o644); err != nil { - t.Fatalf("WriteFile overlay: %v", err) - } - if err := os.WriteFile(workDiskPath, make([]byte, 2048), 0o644); err != nil { - t.Fatalf("WriteFile work disk: %v", err) - } - m.vms = []model.VMRecord{ - { - ID: "vm-1", - Name: "devbox", - State: model.VMStateRunning, - Spec: model.VMSpec{ - VCPUCount: 2, - MemoryMiB: 1024, - WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024, - }, - Runtime: model.VMRuntime{ - SystemOverlay: overlayPath, - WorkDiskPath: workDiskPath, - }, - }, - { - ID: "vm-2", - Name: "db", - State: model.VMStateStopped, - Spec: model.VMSpec{ - VCPUCount: 4, - MemoryMiB: 4096, - WorkDiskSizeBytes: 32 * 1024 * 1024 * 1024, - }, - }, - } - m.selectedID = "vm-1" - m.rebuildTable() - m.refreshDetail() - - view := m.View() - if !strings.Contains(view, "VMs") || !strings.Contains(view, "1/2") { - t.Fatalf("view = %q, want running VM count", view) - } - if !strings.Contains(view, "CPU") || !strings.Contains(view, "2/32") { - t.Fatalf("view = %q, want vcpu aggregate", view) - } - if !strings.Contains(view, "RAM") || !strings.Contains(view, "1.0G/125.0G") { - t.Fatalf("view = %q, want memory aggregate", view) - } - if !strings.Contains(view, "Disk") { - t.Fatalf("view = %q, want disk aggregate", view) - } - if !strings.Contains(view, "█") || !strings.Contains(view, "░") { - t.Fatalf("view = %q, want visual progress bars", view) - } -} - -func TestAggregateVMDiskUsage(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - overlayPath := filepath.Join(dir, "system.cow") - workDiskPath := filepath.Join(dir, "root.ext4") - if err := os.WriteFile(overlayPath, make([]byte, 4096), 0o644); err != nil { - t.Fatalf("WriteFile overlay: %v", err) - } - if err := os.WriteFile(workDiskPath, make([]byte, 8192), 0o644); err != nil { - t.Fatalf("WriteFile work disk: %v", err) - } - - total := aggregateVMDiskUsage([]model.VMRecord{{ - Runtime: model.VMRuntime{ - SystemOverlay: overlayPath, - WorkDiskPath: workDiskPath, - }, - }}) - if total <= 0 { - t.Fatalf("aggregateVMDiskUsage = %d, want positive allocated bytes", total) - } -} From 9f09b0d25c7beaab179250cdbe19689ca1f60d62 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 20 Mar 2026 17:40:52 -0300 Subject: [PATCH 002/257] Show vm create defaults in CLI help Expose the static vm create CPU, memory, system overlay, and work disk defaults at the Cobra flag declaration layer so banger vm create --help shows the values users get by default. Keep the daemon as the fallback source of truth by only sending those fields in VMCreateParams when the user actually changes the flags. This preserves existing RPC behavior for omitted values while improving the CLI UX. Add CLI coverage for displayed defaults and for unchanged versus changed flag propagation. Verified with GOCACHE=/tmp/banger-gocache go test ./... and go run ./cmd/banger vm create --help. --- internal/cli/banger.go | 32 +++++++++++-------- internal/cli/cli_test.go | 69 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/internal/cli/banger.go b/internal/cli/banger.go index b7f3361..9de83e5 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -300,10 +300,10 @@ func newVMCreateCommand() *cobra.Command { var ( name string imageName string - vcpu int - memory int - systemOverlaySize string - workDiskSize string + vcpu = model.DefaultVCPUCount + memory = model.DefaultMemoryMiB + systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize) + workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize) natEnabled bool noStart bool ) @@ -332,10 +332,10 @@ func newVMCreateCommand() *cobra.Command { } cmd.Flags().StringVar(&name, "name", "", "vm name") cmd.Flags().StringVar(&imageName, "image", "", "image name or id") - cmd.Flags().IntVar(&vcpu, "vcpu", 0, "vcpu count") - cmd.Flags().IntVar(&memory, "memory", 0, "memory in MiB") - cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", "", "system overlay size") - cmd.Flags().StringVar(&workDiskSize, "disk-size", "", "work disk size") + cmd.Flags().IntVar(&vcpu, "vcpu", model.DefaultVCPUCount, "vcpu count") + cmd.Flags().IntVar(&memory, "memory", model.DefaultMemoryMiB, "memory in MiB") + cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(model.DefaultSystemOverlaySize), "system overlay size") + cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk size") cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT") cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting") return cmd @@ -1019,12 +1019,10 @@ func vmSetParamsFromFlags(idOrName string, vcpu, memory int, diskSize string, na func vmCreateParamsFromFlags(cmd *cobra.Command, name, imageName string, vcpu, memory int, systemOverlaySize, workDiskSize string, natEnabled, noStart bool) (api.VMCreateParams, error) { params := api.VMCreateParams{ - Name: name, - ImageName: imageName, - SystemOverlaySize: systemOverlaySize, - WorkDiskSize: workDiskSize, - NATEnabled: natEnabled, - NoStart: noStart, + Name: name, + ImageName: imageName, + NATEnabled: natEnabled, + NoStart: noStart, } if cmd.Flags().Changed("vcpu") { if err := validatePositiveSetting("vcpu", vcpu); err != nil { @@ -1038,6 +1036,12 @@ func vmCreateParamsFromFlags(cmd *cobra.Command, name, imageName string, vcpu, m } params.MemoryMiB = &memory } + if cmd.Flags().Changed("system-overlay-size") { + params.SystemOverlaySize = systemOverlaySize + } + if cmd.Flags().Changed("disk-size") { + params.WorkDiskSize = workDiskSize + } return params, nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 54af725..da0869d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -128,6 +128,31 @@ func TestVMCreateFlagsExist(t *testing.T) { } } +func TestVMCreateFlagsShowStaticDefaults(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) + } + + if got := create.Flags().Lookup("vcpu").DefValue; got != fmt.Sprintf("%d", model.DefaultVCPUCount) { + t.Fatalf("vcpu default = %q, want %d", got, model.DefaultVCPUCount) + } + if got := create.Flags().Lookup("memory").DefValue; got != fmt.Sprintf("%d", model.DefaultMemoryMiB) { + t.Fatalf("memory default = %q, want %d", got, model.DefaultMemoryMiB) + } + if got := create.Flags().Lookup("system-overlay-size").DefValue; got != model.FormatSizeBytes(model.DefaultSystemOverlaySize) { + t.Fatalf("system-overlay-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultSystemOverlaySize)) + } + if got := create.Flags().Lookup("disk-size").DefValue; got != model.FormatSizeBytes(model.DefaultWorkDiskSize) { + t.Fatalf("disk-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultWorkDiskSize)) + } +} + func TestImageRegisterFlagsExist(t *testing.T) { root := NewBangerCommand() image, _, err := root.Find([]string{"image"}) @@ -199,7 +224,7 @@ func TestVMSetParamsFromFlags(t *testing.T) { } } -func TestVMCreateParamsFromFlagsUsesNilForOmittedCPUAndMemory(t *testing.T) { +func TestVMCreateParamsFromFlagsOmitsStaticDefaultsWhenFlagsAreUnchanged(t *testing.T) { cmd := NewBangerCommand() vm, _, err := cmd.Find([]string{"vm"}) if err != nil { @@ -210,12 +235,48 @@ func TestVMCreateParamsFromFlagsUsesNilForOmittedCPUAndMemory(t *testing.T) { t.Fatalf("find create: %v", err) } - params, err := vmCreateParamsFromFlags(create, "devbox", "default", 0, 0, "8G", "16G", false, false) + params, err := vmCreateParamsFromFlags( + create, + "devbox", + "default", + model.DefaultVCPUCount, + model.DefaultMemoryMiB, + model.FormatSizeBytes(model.DefaultSystemOverlaySize), + model.FormatSizeBytes(model.DefaultWorkDiskSize), + false, + false, + ) if err != nil { t.Fatalf("vmCreateParamsFromFlags: %v", err) } - if params.VCPUCount != nil || params.MemoryMiB != nil { - t.Fatalf("expected omitted cpu/memory to stay nil: %+v", params) + if params.VCPUCount != nil || params.MemoryMiB != nil || params.SystemOverlaySize != "" || params.WorkDiskSize != "" { + t.Fatalf("expected unchanged defaults to stay omitted: %+v", params) + } +} + +func TestVMCreateParamsFromFlagsIncludesChangedDiskFlags(t *testing.T) { + cmd := NewBangerCommand() + vm, _, err := cmd.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) + } + if err := create.Flags().Set("system-overlay-size", "16G"); err != nil { + t.Fatalf("set system-overlay-size flag: %v", err) + } + if err := create.Flags().Set("disk-size", "32G"); err != nil { + t.Fatalf("set disk-size flag: %v", err) + } + + params, err := vmCreateParamsFromFlags(create, "devbox", "default", model.DefaultVCPUCount, model.DefaultMemoryMiB, "16G", "32G", false, false) + if err != nil { + t.Fatalf("vmCreateParamsFromFlags: %v", err) + } + if params.SystemOverlaySize != "16G" || params.WorkDiskSize != "32G" { + t.Fatalf("expected changed disk flags to be included: %+v", params) } } From 30f0c0b54ab5bea80f1b940a2011467b4fd38ead Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 21 Mar 2026 14:48:01 -0300 Subject: [PATCH 003/257] Manage image artifacts and show VM create progress Stop relying on ad hoc rootfs handling by adding image promotion, managed work-seed fingerprint metadata, and lazy self-healing for older managed images after the first create. Rebuild guest images with baked SSH access, a guest NIC bootstrap, and default opencode services, and add the staged Void kernel/initramfs/modules workflow so void-exp uses a matching Void boot stack. Replace the opaque blocking vm.create RPC with a begin/status flow that prints live stages in the CLI while still waiting for vsock health and opencode on guest port 4096. Validate with GOCACHE=/tmp/banger-gocache go test ./... and live void-exp create/delete smoke runs. --- AGENTS.md | 7 +- Makefile | 8 +- README.md | 66 ++- customize.sh | 28 ++ examples/void-exp.config.toml | 4 + internal/api/types.go | 32 +- internal/cli/banger.go | 172 +++++++- internal/cli/cli_test.go | 100 +++++ internal/daemon/capabilities.go | 14 +- internal/daemon/capabilities_test.go | 12 + internal/daemon/daemon.go | 31 ++ internal/daemon/daemon_test.go | 180 ++++++++ internal/daemon/fastpath_test.go | 42 +- internal/daemon/image_seed.go | 86 ++++ internal/daemon/imagebuild.go | 47 ++- internal/daemon/imagebuild_test.go | 16 +- internal/daemon/images.go | 150 ++++++- internal/daemon/opencode.go | 18 + internal/daemon/vm.go | 66 ++- internal/daemon/vm_create_ops.go | 205 +++++++++ internal/daemon/vm_test.go | 57 ++- internal/guest/ssh.go | 11 + internal/guestnet/assets/bootstrap.sh | 132 ++++++ internal/guestnet/assets/systemd.service | 13 + internal/guestnet/assets/void-core-service.sh | 4 + internal/guestnet/guestnet.go | 30 ++ internal/model/types.go | 29 +- internal/opencode/opencode.go | 104 +++++ internal/opencode/opencode_test.go | 116 ++++++ internal/store/store.go | 19 +- internal/store/store_test.go | 29 +- internal/system/system.go | 3 +- internal/system/system_test.go | 10 + make-rootfs-void.sh | 77 +++- make-void-kernel.sh | 391 ++++++++++++++++++ register-void-image.sh | 90 ++++ verify.sh | 34 +- 37 files changed, 2334 insertions(+), 99 deletions(-) create mode 100644 internal/daemon/image_seed.go create mode 100644 internal/daemon/opencode.go create mode 100644 internal/daemon/vm_create_ops.go create mode 100644 internal/guestnet/assets/bootstrap.sh create mode 100644 internal/guestnet/assets/systemd.service create mode 100644 internal/guestnet/assets/void-core-service.sh create mode 100644 internal/guestnet/guestnet.go create mode 100644 internal/opencode/opencode.go create mode 100644 internal/opencode/opencode_test.go create mode 100755 make-void-kernel.sh create mode 100755 register-void-image.sh diff --git a/AGENTS.md b/AGENTS.md index 9c8df2d..c5d6dc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,8 @@ - `make build` builds `./banger`, `./bangerd`, and the bundled `./runtime/banger-vsock-agent` guest helper. - `make bench-create` benchmarks `vm create` and first-SSH readiness on the current host. - `make runtime-bundle` bootstraps `./runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `runtime-bundle.toml` is only a template. -- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./runtime/`; it does not replace the default Debian path or teach `banger image build` about Void. +- `make void-kernel` downloads and stages a Void `linux6.12` kernel under `./runtime/void-kernel`, including extracted `vmlinux`, raw `vmlinuz`, a matching generated `initramfs`, config, and matching modules. +- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./runtime/`; it prefers staged `./runtime/void-kernel` modules when present, but does not replace the default Debian path or teach `banger image build` about Void. - `make verify-void` registers `void-exp` and runs the normal smoke test against that image. - `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set. - `./banger vm create --name testbox` creates and starts a VM. @@ -34,8 +35,8 @@ - Primary automated coverage is `go test ./...`. - Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM. - For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke. -- Rebuilt images now include `mise`, `opencode`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up. -- The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. Keep further baked-in tooling deliberate and user-driven. +- Rebuilt images now include `mise`, `opencode`, a host-reachable default `opencode` server service on guest TCP port `4096`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up. +- The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, a guest network bootstrap sourced from the kernel `ip=` cmdline, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, the default host-reachable `opencode` server service on guest TCP port `4096`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. When `./runtime/void-kernel/` exists, the Void image registration path expects a complete staged Void kernel, initramfs, and modules tree and points `void-exp` at it. Keep further baked-in tooling deliberate and user-driven. - Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. - The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks. - If you add a new operational workflow, document how to exercise it in `README.md`. diff --git a/Makefile b/Makefile index 2eea15d..36ee25c 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ VOID_VM_NAME ?= void-dev .DEFAULT_GOAL := help -.PHONY: help build banger bangerd test fmt tidy clean rootfs rootfs-void void-register void-vm verify-void install runtime-bundle runtime-package check-runtime bench-create +.PHONY: help build banger bangerd test fmt tidy clean rootfs rootfs-void void-kernel void-register void-vm verify-void install runtime-bundle runtime-package check-runtime bench-create help: @printf '%s\n' \ @@ -39,6 +39,7 @@ help: ' make tidy Run go mod tidy' \ ' make clean Remove built Go binaries' \ ' make rootfs Rebuild the source-checkout default Debian rootfs image in ./runtime' \ + ' make void-kernel Download and stage a Void kernel, initramfs, and modules under ./runtime/void-kernel' \ ' make rootfs-void Build an experimental Void Linux rootfs and work-seed in ./runtime' \ ' make void-register Register or update the experimental Void image as $(VOID_IMAGE_NAME)' \ ' make void-vm Register the experimental Void image and create a VM named $(VOID_VM_NAME)' \ @@ -107,11 +108,14 @@ install: build check-runtime rootfs: BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs.sh +void-kernel: + BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-void-kernel.sh + rootfs-void: BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs-void.sh void-register: build - ./banger image register --name "$(VOID_IMAGE_NAME)" --rootfs "$(abspath $(RUNTIME_SOURCE_DIR))/rootfs-void.ext4" --work-seed "$(abspath $(RUNTIME_SOURCE_DIR))/rootfs-void.work-seed.ext4" --packages "$(abspath packages.void)" + BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" VOID_IMAGE_NAME="$(VOID_IMAGE_NAME)" BANGER_BIN="$(abspath ./banger)" ./register-void-image.sh void-vm: void-register ./banger vm create --image "$(VOID_IMAGE_NAME)" --name "$(VOID_VM_NAME)" diff --git a/README.md b/README.md index 2282fb7..dac9f0b 100644 --- a/README.md +++ b/README.md @@ -212,10 +212,11 @@ banger image build --name docker-dev --docker Rebuilt images install a pinned `mise` at `/usr/local/bin/mise`, activate it for bash login and interactive shells, install `opencode` through `mise`, -configure `tmux-resurrect` plus `tmux-continuum` for `root` with periodic -autosaves and manual-only restore by default, and bake in the -`banger-vsock-agent` systemd service used by the post-SSH reminder path and -guest health checks. They +expose `/usr/local/bin/opencode`, configure `tmux-resurrect` plus +`tmux-continuum` for `root` with periodic autosaves and manual-only restore by +default, start a host-reachable `opencode serve` service on guest TCP port +`4096`, and bake in the `banger-vsock-agent` systemd service used by the +post-SSH reminder path and guest health checks. They also emit a `work-seed.ext4` sidecar that lets new VMs clone a prepared `/root` work disk instead of rebuilding it from scratch on every create. @@ -225,6 +226,17 @@ banger image show docker-dev banger image delete docker-dev ``` +Promote an existing unmanaged image into a managed one: +```bash +banger image promote default +banger image promote void-exp +``` + +Promotion copies the image's `rootfs` and optional `work-seed` into the +daemon's managed image state directory and keeps the same image ID, so existing +VM references stay valid. The image's kernel, initrd, modules, and package +manifest paths stay pointed at their current locations. + `banger` auto-registers the bundled `default_rootfs` image when it exists. If the bundle does not include a separate base `rootfs.ext4`, `image build` falls back to using `rootfs-docker.ext4` as its default base image. @@ -253,6 +265,10 @@ short best-effort HTTP and HTTPS probes; detected web listeners are shown as `https://.vm:port/`. Older images without `ss` may need rebuilding before `vm ports` works. +Newly rebuilt images also start `opencode serve` by default on guest TCP port +`4096`, bound on guest interfaces so the host can reach it directly at the +guest IP or via the endpoint shown by `banger vm ports`. + ## Storage Model - VMs share a read-only base rootfs image. - Each VM gets its own sparse writable system overlay for `/`. @@ -286,7 +302,8 @@ make rootfs ``` That rebuild also regenerates `./runtime/rootfs-docker.work-seed.ext4`, which -the daemon uses to speed up future `vm create` calls. +the daemon uses to speed up future `vm create` calls, and bakes in the default +host-reachable `opencode` server service. If your runtime bundle does not include `./runtime/rootfs.ext4`, pass an explicit base image instead: @@ -303,25 +320,37 @@ make rootfs `make rootfs` expects a bootstrapped runtime bundle. If `./runtime/rootfs.ext4` is not available, pass an explicit `--base-rootfs` to `./make-rootfs.sh`. Existing VMs keep using their current image and disks; rebuilds only affect VMs -created from the rebuilt image afterward. +created from the rebuilt image afterward. Restarting an existing VM is not +enough to pick up guest provisioning changes such as the default `opencode` +server service. ## Experimental Void Rootfs There is also a separate, opt-in builder for an experimental Void Linux guest path: ```bash +make void-kernel make rootfs-void ``` That writes: +- `./runtime/void-kernel/` when `make void-kernel` is used - `./runtime/rootfs-void.ext4` - `./runtime/rootfs-void.work-seed.ext4` This path is intentionally local-only and does not change the default Debian -image flow. It reuses the current runtime bundle kernel, initrd, and modules, -but builds a lean `x86_64-glibc` Void userspace with: +image flow. `make void-kernel` stages an actual Void `linux6.12` kernel package +under `./runtime/void-kernel/`, including the raw `vmlinuz`, extracted +Firecracker `vmlinux`, a matching `initramfs`, the matching config, and the +matching modules tree. The initramfs is generated locally with `dracut` +against the downloaded Void sysroot so the kernel, initrd, and modules stay +aligned. `make rootfs-void` then prefers that staged modules tree when it exists; +otherwise it falls back to the runtime bundle modules. The rootfs builder +itself still builds a lean `x86_64-glibc` Void userspace with: - `bash` installed for interactive/admin use - pinned `mise` installed at `/usr/local/bin/mise`, activated for `root` bash shells - `opencode` installed through `mise`, with `/usr/local/bin/opencode` available by default +- a guest network bootstrap that configures the VM NIC from the kernel `ip=` boot arg +- a host-reachable `opencode serve` runit service enabled on guest TCP port `4096` - `docker` plus `docker-compose` installed from Void packages - the `docker` runit service enabled, with Docker netfilter/forwarding kernel prep - `openssh` enabled under runit @@ -333,26 +362,30 @@ It still keeps some Debian-oriented extras out for now: - no tmux plugin defaults The builder fetches official static XBPS tools and packages from the Void -mirror during the build. It currently supports only `x86_64-glibc`. +mirror during the build. The kernel fetcher and rootfs builder currently +support only `x86_64`. The package set comes from [`packages.void`](/home/thales/projects/personal/banger/packages.void). -You can override the mirror, size, or output path directly: +You can override the mirror, size, output path, or kernel package directly: ```bash +./make-void-kernel.sh --kernel-package linux6.12 ./make-rootfs-void.sh --mirror https://repo-default.voidlinux.org --size 2G ``` The fastest local iteration loop does not require changing your default image config at all: ```bash +make void-kernel make rootfs-void make void-register ./banger vm create --image void-exp --name void-dev ./banger vm ssh void-dev ``` -Rebuild the Void rootfs and recreate existing `void-exp` VMs after changing the -package set or guest provisioning; restart alone will not update the image -contents or `/root` work-seed. +Rebuild the staged Void kernel or Void rootfs, then recreate existing +`void-exp` VMs after changing the package set, guest provisioning, or staged +kernel artifacts; restart alone will not update the image contents, kernel, or +`/root` work-seed. There is also a smoke path for the experimental image: ```bash @@ -361,7 +394,9 @@ make verify-void `make void-register` uses the unmanaged image registration path to create or update a `void-exp` image record in place, so repeated rebuilds do not require -editing `~/.config/banger/config.toml`. +editing `~/.config/banger/config.toml`. It expects a complete staged Void +kernel set under `./runtime/void-kernel/` and points the experimental image at +the staged Void `vmlinux`, `initramfs`, and matching modules tree. There is also a one-step helper target: ```bash @@ -390,6 +425,9 @@ banger image register \ --name void-exp \ --rootfs ./runtime/rootfs-void.ext4 \ --work-seed ./runtime/rootfs-void.work-seed.ext4 \ + --kernel ./runtime/void-kernel/boot/vmlinux-6.12.77_1 \ + --initrd ./runtime/void-kernel/boot/initramfs-6.12.77_1.img \ + --modules ./runtime/void-kernel/lib/modules/6.12.77_1 \ --packages ./packages.void ``` diff --git a/customize.sh b/customize.sh index a290e96..1576382 100755 --- a/customize.sh +++ b/customize.sh @@ -418,6 +418,12 @@ DEBIAN_FRONTEND=noninteractive apt-get -y upgrade DEBIAN_FRONTEND=noninteractive apt-get -y install ${APT_PACKAGES_ESCAPED} curl -fsSL https://mise.run | MISE_INSTALL_PATH=\"$MISE_INSTALL_PATH\" MISE_VERSION=\"$MISE_VERSION\" sh \"$MISE_INSTALL_PATH\" use -g github:anomalyco/opencode +\"$MISE_INSTALL_PATH\" reshim +if [[ ! -e /root/.local/share/mise/shims/opencode ]]; then + echo 'opencode shim not found after mise install' >&2 + exit 1 +fi +ln -snf /root/.local/share/mise/shims/opencode /usr/local/bin/opencode mkdir -p /etc/profile.d cat > /etc/profile.d/mise.sh <<'MISEPROFILE' if [ -n \"\${BASH_VERSION:-}\" ] && [ -x \"$MISE_INSTALL_PATH\" ]; then @@ -441,6 +447,28 @@ fi rm -f /root/get-docker /root/get-docker.sh /tmp/get-docker /tmp/get-docker.sh chmod 0755 /usr/local/bin/banger-vsock-agent mkdir -p /etc/modules-load.d /etc/systemd/system +cat > /etc/systemd/system/banger-opencode.service <<'EOF' +[Unit] +Description=Banger opencode server +After=network.target +RequiresMountsFor=/root + +[Service] +Type=simple +Environment=HOME=/root +WorkingDirectory=/root +ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096 +Restart=on-failure +RestartSec=1 + +[Install] +WantedBy=multi-user.target +EOF +chmod 0644 /etc/systemd/system/banger-opencode.service +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl enable --now banger-opencode.service || true +fi cat > /etc/modules-load.d/banger-vsock.conf <<'EOF' vsock vmw_vsock_virtio_transport diff --git a/examples/void-exp.config.toml b/examples/void-exp.config.toml index 75ee7e2..a3a1b89 100644 --- a/examples/void-exp.config.toml +++ b/examples/void-exp.config.toml @@ -3,8 +3,12 @@ # Copy the values you want into ~/.config/banger/config.toml and replace # /abs/path/to/banger with your checkout path. Do not set default_base_rootfs # to the Void image yet; banger image build still assumes the Debian flow. +# If you run `make void-kernel`, also merge the commented kernel/initrd/modules lines. runtime_dir = "/abs/path/to/banger/runtime" default_image_name = "void-exp" default_rootfs = "/abs/path/to/banger/runtime/rootfs-void.ext4" default_work_seed = "/abs/path/to/banger/runtime/rootfs-void.work-seed.ext4" +# default_kernel = "/abs/path/to/banger/runtime/void-kernel/boot/vmlinux-6.12.77_1" +# default_initrd = "/abs/path/to/banger/runtime/void-kernel/boot/initramfs-6.12.77_1.img" +# default_modules_dir = "/abs/path/to/banger/runtime/void-kernel/lib/modules/6.12.77_1" diff --git a/internal/api/types.go b/internal/api/types.go index 55c962f..77ec00e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -1,6 +1,10 @@ package api -import "banger/internal/model" +import ( + "time" + + "banger/internal/model" +) type Empty struct{} @@ -24,6 +28,32 @@ type VMCreateParams struct { NoStart bool `json:"no_start,omitempty"` } +type VMCreateStatusParams struct { + ID string `json:"id"` +} + +type VMCreateOperation struct { + ID string `json:"id"` + VMID string `json:"vm_id,omitempty"` + VMName string `json:"vm_name,omitempty"` + Stage string `json:"stage,omitempty"` + Detail string `json:"detail,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Done bool `json:"done"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + VM *model.VMRecord `json:"vm,omitempty"` +} + +type VMCreateBeginResult struct { + Operation VMCreateOperation `json:"operation"` +} + +type VMCreateStatusResult struct { + Operation VMCreateOperation `json:"operation"` +} + type VMRefParams struct { IDOrName string `json:"id_or_name"` } diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 9de83e5..0d53a05 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -46,6 +46,16 @@ var ( vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName}) } + vmCreateBeginFunc = func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error) { + return rpc.Call[api.VMCreateBeginResult](ctx, socketPath, "vm.create.begin", params) + } + vmCreateStatusFunc = func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error) { + return rpc.Call[api.VMCreateStatusResult](ctx, socketPath, "vm.create.status", api.VMCreateStatusParams{ID: operationID}) + } + vmCreateCancelFunc = func(ctx context.Context, socketPath, operationID string) error { + _, err := rpc.Call[api.Empty](ctx, socketPath, "vm.create.cancel", api.VMCreateStatusParams{ID: operationID}) + return err + } vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) { return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName}) } @@ -323,11 +333,11 @@ func newVMCreateCommand() *cobra.Command { if err != nil { return err } - result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.create", params) + vm, err := runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params) if err != nil { return err } - return printVMSummary(cmd.OutOrStdout(), result.VM) + return printVMSummary(cmd.OutOrStdout(), vm) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") @@ -575,6 +585,7 @@ func newImageCommand() *cobra.Command { cmd.AddCommand( newImageBuildCommand(), newImageRegisterCommand(), + newImagePromoteCommand(), newImageListCommand(), newImageShowCommand(), newImageDeleteCommand(), @@ -651,6 +662,28 @@ func newImageRegisterCommand() *cobra.Command { return cmd } +func newImagePromoteCommand() *cobra.Command { + return &cobra.Command{ + Use: "promote ", + Short: "Promote an unmanaged image to a managed artifact", + Args: exactArgsUsage(1, "usage: banger image promote "), + 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.promote", api.ImageRefParams{IDOrName: args[0]}) + if err != nil { + return err + } + return printImageSummary(cmd.OutOrStdout(), result.Image) + }, + } +} + func newImageListCommand() *cobra.Command { return &cobra.Command{ Use: "list", @@ -1255,6 +1288,141 @@ type anyWriter interface { Write(p []byte) (n int, err error) } +func runVMCreate(ctx context.Context, socketPath string, stderr io.Writer, params api.VMCreateParams) (model.VMRecord, error) { + begin, err := vmCreateBeginFunc(ctx, socketPath, params) + if err != nil { + return model.VMRecord{}, err + } + renderer := newVMCreateProgressRenderer(stderr) + renderer.render(begin.Operation) + + op := begin.Operation + for { + if op.Done { + renderer.render(op) + if op.Success && op.VM != nil { + return *op.VM, nil + } + if strings.TrimSpace(op.Error) == "" { + return model.VMRecord{}, errors.New("vm create failed") + } + return model.VMRecord{}, errors.New(op.Error) + } + + select { + case <-ctx.Done(): + cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = vmCreateCancelFunc(cancelCtx, socketPath, op.ID) + return model.VMRecord{}, ctx.Err() + case <-time.After(200 * time.Millisecond): + } + + status, err := vmCreateStatusFunc(ctx, socketPath, op.ID) + if err != nil { + if ctx.Err() != nil { + cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = vmCreateCancelFunc(cancelCtx, socketPath, op.ID) + return model.VMRecord{}, ctx.Err() + } + return model.VMRecord{}, err + } + op = status.Operation + renderer.render(op) + } +} + +type vmCreateProgressRenderer struct { + out io.Writer + enabled bool + lastLine string +} + +func newVMCreateProgressRenderer(out io.Writer) *vmCreateProgressRenderer { + return &vmCreateProgressRenderer{ + out: out, + enabled: writerSupportsProgress(out), + } +} + +func (r *vmCreateProgressRenderer) render(op api.VMCreateOperation) { + if r == nil || !r.enabled { + return + } + line := formatVMCreateProgress(op) + if line == "" || line == r.lastLine { + return + } + r.lastLine = line + _, _ = fmt.Fprintln(r.out, line) +} + +func writerSupportsProgress(out io.Writer) bool { + file, ok := out.(*os.File) + if !ok { + return false + } + info, err := file.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +func formatVMCreateProgress(op api.VMCreateOperation) string { + stage := strings.TrimSpace(op.Stage) + detail := strings.TrimSpace(op.Detail) + label := vmCreateStageLabel(stage) + if label == "" && detail == "" { + return "" + } + if label == "" { + return "[vm create] " + detail + } + if detail == "" { + return "[vm create] " + label + } + return "[vm create] " + label + ": " + detail +} + +func vmCreateStageLabel(stage string) string { + switch strings.TrimSpace(stage) { + case "queued": + return "queued" + case "resolve_image": + return "resolving image" + case "reserve_vm": + return "allocating vm" + case "preflight": + return "checking host prerequisites" + case "prepare_rootfs": + return "preparing root filesystem" + case "prepare_host_features": + return "preparing host features" + case "prepare_work_disk": + return "preparing work disk" + case "boot_firecracker": + return "starting firecracker" + case "wait_vsock_agent": + return "waiting for vsock agent" + case "wait_guest_ready": + return "waiting for guest services" + case "wait_opencode": + return "waiting for opencode" + case "apply_dns": + return "publishing dns" + case "apply_nat": + return "configuring nat" + case "finalize": + return "finalizing" + case "ready": + return "ready" + default: + return strings.ReplaceAll(stage, "_", " ") + } +} + func shortID(id string) string { if len(id) <= 12 { return id diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index da0869d..e64f6be 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -170,6 +170,17 @@ func TestImageRegisterFlagsExist(t *testing.T) { } } +func TestImagePromoteCommandExists(t *testing.T) { + root := NewBangerCommand() + image, _, err := root.Find([]string{"image"}) + if err != nil { + t.Fatalf("find image: %v", err) + } + if _, _, err := image.Find([]string{"promote"}); err != nil { + t.Fatalf("find promote: %v", err) + } +} + func TestVMKillFlagsExist(t *testing.T) { root := NewBangerCommand() vm, _, err := root.Find([]string{"vm"}) @@ -304,6 +315,95 @@ func TestVMCreateParamsFromFlagsRejectsNonPositiveCPUAndMemory(t *testing.T) { } } +func TestRunVMCreatePollsUntilDone(t *testing.T) { + origBegin := vmCreateBeginFunc + origStatus := vmCreateStatusFunc + origCancel := vmCreateCancelFunc + t.Cleanup(func() { + vmCreateBeginFunc = origBegin + vmCreateStatusFunc = origStatus + vmCreateCancelFunc = origCancel + }) + + vm := model.VMRecord{ + ID: "vm-id", + Name: "devbox", + Spec: model.VMSpec{WorkDiskSizeBytes: model.DefaultWorkDiskSize}, + Runtime: model.VMRuntime{ + State: model.VMStateRunning, + GuestIP: "172.16.0.2", + DNSName: "devbox.vm", + }, + } + vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) { + return api.VMCreateBeginResult{ + Operation: api.VMCreateOperation{ + ID: "op-1", + Stage: "prepare_work_disk", + Detail: "cloning work seed", + }, + }, nil + } + statusCalls := 0 + vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) { + statusCalls++ + if statusCalls == 1 { + return api.VMCreateStatusResult{ + Operation: api.VMCreateOperation{ + ID: "op-1", + Stage: "wait_opencode", + Detail: "waiting for opencode on guest port 4096", + }, + }, nil + } + return api.VMCreateStatusResult{ + Operation: api.VMCreateOperation{ + ID: "op-1", + Stage: "ready", + Detail: "vm is ready", + Done: true, + Success: true, + VM: &vm, + }, + }, nil + } + vmCreateCancelFunc = func(context.Context, string, string) error { + t.Fatal("cancel should not be called") + return nil + } + + got, err := runVMCreate(context.Background(), "/tmp/bangerd.sock", &bytes.Buffer{}, api.VMCreateParams{Name: "devbox"}) + if err != nil { + t.Fatalf("runVMCreate: %v", err) + } + if got.Name != vm.Name || got.Runtime.GuestIP != vm.Runtime.GuestIP { + t.Fatalf("vm = %+v, want %+v", got, vm) + } + if statusCalls != 2 { + t.Fatalf("statusCalls = %d, want 2", statusCalls) + } +} + +func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) { + var stderr bytes.Buffer + renderer := &vmCreateProgressRenderer{out: &stderr, enabled: true} + + renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"}) + renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"}) + renderer.render(api.VMCreateOperation{Stage: "wait_opencode", Detail: "waiting for opencode on guest port 4096"}) + + lines := strings.Split(strings.TrimSpace(stderr.String()), "\n") + if len(lines) != 2 { + t.Fatalf("rendered lines = %q, want 2 lines", stderr.String()) + } + if lines[0] != "[vm create] preparing work disk: cloning work seed" { + t.Fatalf("first line = %q", lines[0]) + } + if lines[1] != "[vm create] waiting for opencode: waiting for opencode on guest port 4096" { + t.Fatalf("second line = %q", lines[1]) + } +} + func TestVMSetParamsFromFlagsConflict(t *testing.T) { if _, err := vmSetParamsFromFlags("devbox", -1, -1, "", true, true); err == nil { t.Fatal("expected nat conflict error") diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index c5dabb8..0b866bc 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -56,6 +56,7 @@ func (d *Daemon) registeredCapabilities() []vmCapability { } return []vmCapability{ workDiskCapability{}, + opencodeCapability{}, dnsCapability{}, natCapability{}, } @@ -103,6 +104,14 @@ func (d *Daemon) prepareCapabilityHosts(ctx context.Context, vm *model.VMRecord, func (d *Daemon) postStartCapabilities(ctx context.Context, vm model.VMRecord, image model.Image) error { for _, capability := range d.registeredCapabilities() { + switch capability.Name() { + case "dns": + vmCreateStage(ctx, "apply_dns", "publishing vm dns record") + case "nat": + if vm.Spec.NATEnabled { + vmCreateStage(ctx, "apply_nat", "configuring nat") + } + } if hook, ok := capability.(postStartCapability); ok { if err := hook.PostStart(ctx, d, vm, image); err != nil { return err @@ -191,10 +200,11 @@ func (workDiskCapability) ContributeMachine(cfg *firecracker.MachineConfig, vm m } func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.VMRecord, image model.Image) error { - if err := d.ensureWorkDisk(ctx, vm, image); err != nil { + prep, err := d.ensureWorkDisk(ctx, vm, image) + if err != nil { return err } - return d.ensureAuthorizedKeyOnWorkDisk(ctx, vm) + return d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep) } func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) { diff --git a/internal/daemon/capabilities_test.go b/internal/daemon/capabilities_test.go index f2ae707..13a6350 100644 --- a/internal/daemon/capabilities_test.go +++ b/internal/daemon/capabilities_test.go @@ -143,3 +143,15 @@ func TestContributeHooksPopulateGuestAndMachineConfig(t *testing.T) { t.Fatalf("guest fstab = %q, want %q", fstab, want) } } + +func TestRegisteredCapabilitiesIncludeOpencode(t *testing.T) { + d := &Daemon{} + var names []string + for _, capability := range d.registeredCapabilities() { + names = append(names, capability.Name()) + } + want := []string{"work-disk", "opencode", "dns", "nat"} + if !reflect.DeepEqual(names, want) { + t.Fatalf("capabilities = %v, want %v", names, want) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a226d15..6eb0ea6 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -32,6 +32,8 @@ type Daemon struct { runner system.CommandRunner logger *slog.Logger mu sync.Mutex + createOpsMu sync.Mutex + createOps map[string]*vmCreateOperationState vmLocksMu sync.Mutex vmLocks map[string]*sync.Mutex tapPoolMu sync.Mutex @@ -249,6 +251,27 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } vm, err := d.CreateVM(ctx, params) return marshalResultOrError(api.VMShowResult{VM: vm}, err) + case "vm.create.begin": + params, err := rpc.DecodeParams[api.VMCreateParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + op, err := d.BeginVMCreate(ctx, params) + return marshalResultOrError(api.VMCreateBeginResult{Operation: op}, err) + case "vm.create.status": + params, err := rpc.DecodeParams[api.VMCreateStatusParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + op, err := d.VMCreateStatus(ctx, params.ID) + return marshalResultOrError(api.VMCreateStatusResult{Operation: op}, err) + case "vm.create.cancel": + params, err := rpc.DecodeParams[api.VMCreateStatusParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + err = d.CancelVMCreate(ctx, params.ID) + return marshalResultOrError(api.Empty{}, err) case "vm.list": vms, err := d.store.ListVMs(ctx) return marshalResultOrError(api.VMListResult{VMs: vms}, err) @@ -376,6 +399,13 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } image, err := d.RegisterImage(ctx, params) return marshalResultOrError(api.ImageShowResult{Image: image}, err) + case "image.promote": + params, err := rpc.DecodeParams[api.ImageRefParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + image, err := d.PromoteImage(ctx, params.IDOrName) + return marshalResultOrError(api.ImageShowResult{Image: image}, err) case "image.delete": params, err := rpc.DecodeParams[api.ImageRefParams](req) if err != nil { @@ -405,6 +435,7 @@ func (d *Daemon) backgroundLoop() { if err := d.stopStaleVMs(context.Background()); err != nil && d.logger != nil { d.logger.Error("background stale sweep failed", "error", err.Error()) } + d.pruneVMCreateOperations(time.Now().Add(-10 * time.Minute)) } } } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 7ecd4e2..cf5ef8a 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -2,6 +2,7 @@ package daemon import ( "bufio" + "bytes" "context" "encoding/json" "net" @@ -13,6 +14,7 @@ import ( "banger/internal/api" "banger/internal/model" + "banger/internal/paths" "banger/internal/rpc" "banger/internal/store" ) @@ -368,6 +370,178 @@ func TestRegisterImageRejectsManagedOverwrite(t *testing.T) { } } +func TestPromoteImageCopiesArtifactsAndPreservesIdentity(t *testing.T) { + dir := t.TempDir() + rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) + workSeed := filepath.Join(dir, "rootfs-docker.work-seed.ext4") + workSeedContent := []byte("seed-data") + if err := os.WriteFile(workSeed, workSeedContent, 0o644); err != nil { + t.Fatalf("WriteFile(workSeed): %v", err) + } + + db := openDefaultImageStore(t, dir) + now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) + existing := model.Image{ + ID: "promote-image-id", + Name: "default", + Managed: false, + RootfsPath: rootfs, + WorkSeedPath: workSeed, + KernelPath: kernel, + InitrdPath: initrd, + ModulesDir: modulesDir, + PackagesPath: packages, + Docker: true, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.UpsertImage(context.Background(), existing); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + vm := testVM("uses-default", existing.ID, "172.16.0.44") + if err := db.UpsertVM(context.Background(), vm); err != nil { + t.Fatalf("UpsertVM: %v", err) + } + + d := &Daemon{ + layout: modelPathsLayoutForTest(dir), + store: db, + } + + image, err := d.PromoteImage(context.Background(), "default") + if err != nil { + t.Fatalf("PromoteImage: %v", err) + } + if !image.Managed { + t.Fatal("promoted image should be managed") + } + if image.ID != existing.ID || image.Name != existing.Name { + t.Fatalf("promoted image identity changed: %+v", image) + } + if !image.CreatedAt.Equal(existing.CreatedAt) { + t.Fatalf("CreatedAt = %s, want preserved %s", image.CreatedAt, existing.CreatedAt) + } + if !image.UpdatedAt.After(existing.UpdatedAt) { + t.Fatalf("UpdatedAt = %s, want newer than %s", image.UpdatedAt, existing.UpdatedAt) + } + wantArtifactDir := filepath.Join(d.layout.ImagesDir, existing.ID) + if image.ArtifactDir != wantArtifactDir { + t.Fatalf("ArtifactDir = %q, want %q", image.ArtifactDir, wantArtifactDir) + } + if image.RootfsPath != filepath.Join(wantArtifactDir, "rootfs.ext4") { + t.Fatalf("RootfsPath = %q, want managed copy", image.RootfsPath) + } + if image.WorkSeedPath != filepath.Join(wantArtifactDir, "work-seed.ext4") { + t.Fatalf("WorkSeedPath = %q, want managed copy", image.WorkSeedPath) + } + if image.KernelPath != kernel || image.InitrdPath != initrd || image.ModulesDir != modulesDir || image.PackagesPath != packages { + t.Fatalf("boot support paths changed unexpectedly: %+v", image) + } + + rootfsContent, err := os.ReadFile(rootfs) + if err != nil { + t.Fatalf("ReadFile(rootfs): %v", err) + } + managedRootfsContent, err := os.ReadFile(image.RootfsPath) + if err != nil { + t.Fatalf("ReadFile(managed rootfs): %v", err) + } + if !bytes.Equal(managedRootfsContent, rootfsContent) { + t.Fatal("managed rootfs copy content mismatch") + } + managedWorkSeedContent, err := os.ReadFile(image.WorkSeedPath) + if err != nil { + t.Fatalf("ReadFile(managed work seed): %v", err) + } + if !bytes.Equal(managedWorkSeedContent, workSeedContent) { + t.Fatal("managed work seed copy content mismatch") + } + + got, err := db.GetImageByName(context.Background(), "default") + if err != nil { + t.Fatalf("GetImageByName: %v", err) + } + if got.RootfsPath != image.RootfsPath || !got.Managed || got.ArtifactDir != image.ArtifactDir { + t.Fatalf("stored promoted image = %+v, want %+v", got, image) + } + gotVM, err := db.GetVMByID(context.Background(), vm.ID) + if err != nil { + t.Fatalf("GetVMByID: %v", err) + } + if gotVM.ImageID != existing.ID { + t.Fatalf("VM image ID = %q, want preserved %q", gotVM.ImageID, existing.ID) + } +} + +func TestPromoteImageRejectsManagedImage(t *testing.T) { + dir := t.TempDir() + rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) + db := openDefaultImageStore(t, dir) + now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) + if err := db.UpsertImage(context.Background(), model.Image{ + ID: "managed-id", + Name: "default", + Managed: true, + ArtifactDir: filepath.Join(dir, "images", "managed-id"), + RootfsPath: rootfs, + KernelPath: kernel, + InitrdPath: initrd, + ModulesDir: modulesDir, + PackagesPath: packages, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + d := &Daemon{ + layout: modelPathsLayoutForTest(dir), + store: db, + } + + _, err := d.PromoteImage(context.Background(), "default") + if err == nil || !strings.Contains(err.Error(), "already managed") { + t.Fatalf("PromoteImage(managed) error = %v", err) + } +} + +func TestPromoteImageSkipsMissingWorkSeed(t *testing.T) { + dir := t.TempDir() + rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) + db := openDefaultImageStore(t, dir) + now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) + existing := model.Image{ + ID: "promote-missing-seed", + Name: "default", + Managed: false, + RootfsPath: rootfs, + WorkSeedPath: filepath.Join(dir, "missing.work-seed.ext4"), + KernelPath: kernel, + InitrdPath: initrd, + ModulesDir: modulesDir, + PackagesPath: packages, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.UpsertImage(context.Background(), existing); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + d := &Daemon{ + layout: modelPathsLayoutForTest(dir), + store: db, + } + + image, err := d.PromoteImage(context.Background(), "default") + if err != nil { + t.Fatalf("PromoteImage: %v", err) + } + if image.WorkSeedPath != "" { + t.Fatalf("WorkSeedPath = %q, want empty for missing source work seed", image.WorkSeedPath) + } + if _, err := os.Stat(filepath.Join(image.ArtifactDir, "work-seed.ext4")); !os.IsNotExist(err) { + t.Fatalf("managed work-seed should not exist, stat error = %v", err) + } +} + func openDefaultImageStore(t *testing.T, dir string) *store.Store { t.Helper() db, err := store.Open(filepath.Join(dir, "state.db")) @@ -405,6 +579,12 @@ func writeDefaultImageArtifacts(t *testing.T, dir string) (rootfs, kernel, initr return rootfs, kernel, initrd, modulesDir, packages } +func modelPathsLayoutForTest(dir string) paths.Layout { + return paths.Layout{ + ImagesDir: filepath.Join(dir, "images"), + } +} + func TestStartVMDNSFailsWhenAddressBusy(t *testing.T) { t.Parallel() diff --git a/internal/daemon/fastpath_test.go b/internal/daemon/fastpath_test.go index b0f327d..aeafe7e 100644 --- a/internal/daemon/fastpath_test.go +++ b/internal/daemon/fastpath_test.go @@ -2,12 +2,17 @@ package daemon import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "errors" "os" "path/filepath" "strconv" "testing" + "banger/internal/guest" "banger/internal/model" ) @@ -34,7 +39,7 @@ func TestEnsureWorkDiskClonesSeedImageAndResizes(t *testing.T) { image := testImage("image-seeded") image.WorkSeedPath = seedPath - if err := d.ensureWorkDisk(context.Background(), &vm, image); err != nil { + if _, err := d.ensureWorkDisk(context.Background(), &vm, image); err != nil { t.Fatalf("ensureWorkDisk: %v", err) } runner.assertExhausted() @@ -90,3 +95,38 @@ func TestTapPoolWarmsAndReusesIdleTap(t *testing.T) { } runner.assertExhausted() } + +func TestEnsureAuthorizedKeyOnWorkDiskSkipsRepairForMatchingSeededFingerprint(t *testing.T) { + t.Parallel() + + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + sshKeyPath := filepath.Join(t.TempDir(), "id_rsa") + if err := os.WriteFile(sshKeyPath, privateKeyPEM, 0o600); err != nil { + t.Fatalf("WriteFile(private key): %v", err) + } + fingerprint, err := guest.AuthorizedPublicKeyFingerprint(sshKeyPath) + if err != nil { + t.Fatalf("AuthorizedPublicKeyFingerprint: %v", err) + } + + runner := &scriptedRunner{t: t} + d := &Daemon{ + runner: runner, + config: model.DaemonConfig{SSHKeyPath: sshKeyPath}, + } + vm := testVM("seeded-fastpath", "image-seeded-fastpath", "172.16.0.62") + vm.Runtime.WorkDiskPath = filepath.Join(t.TempDir(), "root.ext4") + image := model.Image{SeededSSHPublicKeyFingerprint: fingerprint} + + if err := d.ensureAuthorizedKeyOnWorkDisk(context.Background(), &vm, image, workDiskPreparation{ClonedFromSeed: true}); err != nil { + t.Fatalf("ensureAuthorizedKeyOnWorkDisk: %v", err) + } + runner.assertExhausted() +} diff --git a/internal/daemon/image_seed.go b/internal/daemon/image_seed.go new file mode 100644 index 0000000..97f6c34 --- /dev/null +++ b/internal/daemon/image_seed.go @@ -0,0 +1,86 @@ +package daemon + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "banger/internal/guest" + "banger/internal/model" + "banger/internal/system" +) + +func (d *Daemon) seedAuthorizedKeyOnExt4Image(ctx context.Context, imagePath string) (string, error) { + if strings.TrimSpace(d.config.SSHKeyPath) == "" { + return "", nil + } + fingerprint, err := guest.AuthorizedPublicKeyFingerprint(d.config.SSHKeyPath) + if err != nil { + return "", fmt.Errorf("derive authorized ssh key fingerprint: %w", err) + } + publicKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) + if err != nil { + return "", fmt.Errorf("derive authorized ssh key: %w", err) + } + mountDir, cleanup, err := system.MountTempDir(ctx, d.runner, imagePath, false) + if err != nil { + return "", err + } + defer cleanup() + + if err := d.flattenNestedWorkHome(ctx, mountDir); err != nil { + return "", err + } + + sshDir := filepath.Join(mountDir, ".ssh") + if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil { + return "", err + } + if _, err := d.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil { + return "", err + } + + authorizedKeysPath := filepath.Join(sshDir, "authorized_keys") + existing, err := d.runner.RunSudo(ctx, "cat", authorizedKeysPath) + if err != nil { + existing = nil + } + merged := mergeAuthorizedKey(existing, publicKey) + tmpFile, err := os.CreateTemp("", "banger-image-authorized-keys-*") + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write(merged); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return "", err + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + defer os.Remove(tmpPath) + if _, err := d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil { + return "", err + } + return fingerprint, nil +} + +func (d *Daemon) refreshManagedWorkSeedFingerprint(ctx context.Context, image model.Image, fingerprint string) error { + if !image.Managed || strings.TrimSpace(image.WorkSeedPath) == "" || strings.TrimSpace(fingerprint) == "" { + return nil + } + seededFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, image.WorkSeedPath) + if err != nil { + return err + } + if seededFingerprint == "" || seededFingerprint == image.SeededSSHPublicKeyFingerprint { + return nil + } + image.SeededSSHPublicKeyFingerprint = seededFingerprint + image.UpdatedAt = model.Now() + return d.store.UpsertImage(ctx, image) +} diff --git a/internal/daemon/imagebuild.go b/internal/daemon/imagebuild.go index 7848335..bccf6f3 100644 --- a/internal/daemon/imagebuild.go +++ b/internal/daemon/imagebuild.go @@ -14,8 +14,10 @@ import ( "banger/internal/firecracker" "banger/internal/guest" + "banger/internal/guestnet" "banger/internal/hostnat" "banger/internal/model" + "banger/internal/opencode" "banger/internal/system" "banger/internal/vsockagent" ) @@ -103,6 +105,10 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) ( return err } defer client.Close() + authorizedKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) + if err != nil { + return err + } helperBytes, err := os.ReadFile(d.config.VSockAgentPath) if err != nil { @@ -117,7 +123,7 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) ( if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil { return err } - if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, packages, spec.InstallDocker), spec.BuildLog); err != nil { + if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), packages, spec.InstallDocker), spec.BuildLog); err != nil { return err } if strings.TrimSpace(spec.ModulesDir) != "" { @@ -250,7 +256,7 @@ func (d *Daemon) shutdownImageBuildVM(ctx context.Context, vm imageBuildVM) erro return d.waitForExit(ctx, vm.PID, vm.APISock, 15*time.Second) } -func buildProvisionScript(vmName, dnsServer string, packages []string, installDocker bool) string { +func buildProvisionScript(vmName, dnsServer, authorizedKey string, packages []string, installDocker bool) string { var script bytes.Buffer script.WriteString("set -euo pipefail\n") fmt.Fprintf(&script, "printf 'nameserver %%s\\n' %s > /etc/resolv.conf\n", shellQuote(dnsServer)) @@ -260,11 +266,14 @@ func buildProvisionScript(vmName, dnsServer string, packages []string, installDo script.WriteString("sed -i '\\|^/dev/vdb[[:space:]]\\+/home[[:space:]]|d; \\|^/dev/vdc[[:space:]]\\+/var[[:space:]]|d' /etc/fstab\n") script.WriteString("if ! grep -q '^tmpfs /run ' /etc/fstab; then echo 'tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0' >> /etc/fstab; fi\n") script.WriteString("if ! grep -q '^tmpfs /tmp ' /etc/fstab; then echo 'tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0' >> /etc/fstab; fi\n") + appendAuthorizedKeySetup(&script, authorizedKey) script.WriteString("apt-get update\n") script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y upgrade\n") fmt.Fprintf(&script, "PACKAGES=%s\n", shellArray(packages)) script.WriteString("DEBIAN_FRONTEND=noninteractive apt-get -y install \"${PACKAGES[@]}\"\n") + appendGuestNetworkSetup(&script) appendMiseSetup(&script) + appendOpenCodeServiceSetup(&script) appendTmuxSetup(&script) appendVSockPingSetup(&script) if installDocker { @@ -279,6 +288,15 @@ func buildProvisionScript(vmName, dnsServer string, packages []string, installDo return script.String() } +func appendAuthorizedKeySetup(script *bytes.Buffer, authorizedKey string) { + script.WriteString("mkdir -p /root/.ssh\n") + script.WriteString("chmod 700 /root/.ssh\n") + script.WriteString("cat > /root/.ssh/authorized_keys <<'EOF'\n") + script.WriteString(strings.TrimSpace(authorizedKey)) + script.WriteString("\nEOF\n") + script.WriteString("chmod 600 /root/.ssh/authorized_keys\n") +} + func buildModulesCommand(modulesBase string) string { return fmt.Sprintf("bash -se <<'EOF'\nset -euo pipefail\nmkdir -p /lib/modules\ntar -C /lib/modules -xf -\ndepmod -a %s\nmkdir -p /etc/modules-load.d\nprintf 'nf_tables\\nnft_chain_nat\\nveth\\nbr_netfilter\\noverlay\\n' > /etc/modules-load.d/docker-netfilter.conf\nmkdir -p /etc/sysctl.d\ncat > /etc/sysctl.d/99-docker.conf <<'SYSCTL'\nnet.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\nSYSCTL\nsysctl --system >/dev/null 2>&1 || true\nEOF", shellQuote(modulesBase)) } @@ -286,6 +304,9 @@ func buildModulesCommand(modulesBase string) string { func appendMiseSetup(script *bytes.Buffer) { fmt.Fprintf(script, "curl -fsSL https://mise.run | MISE_INSTALL_PATH=%s MISE_VERSION=%s sh\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultMiseVersion)) fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultOpenCodeTool)) + fmt.Fprintf(script, "%s reshim\n", shellQuote(defaultMiseInstallPath)) + fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi\n", shellQuote(opencode.ShimPath)) + fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(opencode.ShimPath), shellQuote(opencode.GuestBinaryPath)) script.WriteString("mkdir -p /etc/profile.d\n") script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n") fmt.Fprintf(script, "if [ -n \"${BASH_VERSION:-}\" ] && [ -x %s ]; then\n", shellQuote(defaultMiseInstallPath)) @@ -296,6 +317,28 @@ func appendMiseSetup(script *bytes.Buffer) { appendLineIfMissing(script, "/etc/bash.bashrc", defaultMiseActivateLine) } +func appendGuestNetworkSetup(script *bytes.Buffer) { + script.WriteString("mkdir -p /usr/local/libexec /etc/systemd/system\n") + script.WriteString("cat > " + guestnet.GuestScriptPath + " <<'EOF'\n") + script.WriteString(guestnet.BootstrapScript()) + script.WriteString("EOF\n") + script.WriteString("chmod 0755 " + guestnet.GuestScriptPath + "\n") + script.WriteString("cat > /etc/systemd/system/" + guestnet.SystemdServiceName + " <<'EOF'\n") + script.WriteString(guestnet.SystemdServiceUnit()) + script.WriteString("EOF\n") + script.WriteString("chmod 0644 /etc/systemd/system/" + guestnet.SystemdServiceName + "\n") + script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + guestnet.SystemdServiceName + " || true; fi\n") +} + +func appendOpenCodeServiceSetup(script *bytes.Buffer) { + script.WriteString("mkdir -p /etc/systemd/system\n") + script.WriteString("cat > /etc/systemd/system/" + opencode.ServiceName + " <<'EOF'\n") + script.WriteString(opencode.ServiceUnit()) + script.WriteString("EOF\n") + script.WriteString("chmod 0644 /etc/systemd/system/" + opencode.ServiceName + "\n") + script.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true; systemctl enable --now " + opencode.ServiceName + " || true; fi\n") +} + func appendTmuxSetup(script *bytes.Buffer) { fmt.Fprintf(script, "TMUX_PLUGIN_DIR=%s\n", shellQuote(defaultTMUXPluginDir)) fmt.Fprintf(script, "TMUX_RESURRECT_DIR=%s\n", shellQuote(defaultTMUXResurrectDir)) diff --git a/internal/daemon/imagebuild_test.go b/internal/daemon/imagebuild_test.go index f8cc09d..3a42612 100644 --- a/internal/daemon/imagebuild_test.go +++ b/internal/daemon/imagebuild_test.go @@ -8,14 +8,28 @@ import ( func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) { t.Parallel() - script := buildProvisionScript("devbox", "1.1.1.1", []string{"git", "curl"}, false) + script := buildProvisionScript("devbox", "1.1.1.1", "ssh-ed25519 AAAATESTKEY banger", []string{"git", "curl"}, false) for _, snippet := range []string{ + "mkdir -p /root/.ssh", + "cat > /root/.ssh/authorized_keys <<'EOF'", + "ssh-ed25519 AAAATESTKEY banger", + "cat > /usr/local/libexec/banger-network-bootstrap <<'EOF'", + "ip addr replace \"$guest_ip/$prefix\" dev \"$iface\"", + "cat > /etc/systemd/system/banger-network.service <<'EOF'", + "systemctl enable --now banger-network.service || true", "curl -fsSL https://mise.run | MISE_INSTALL_PATH='/usr/local/bin/mise' MISE_VERSION='v2025.12.0' sh", "'/usr/local/bin/mise' use -g 'github:anomalyco/opencode'", + "'/usr/local/bin/mise' reshim", + "if [[ ! -e '/root/.local/share/mise/shims/opencode' ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi", + "ln -snf '/root/.local/share/mise/shims/opencode' '/usr/local/bin/opencode'", "cat > /etc/profile.d/mise.sh <<'EOF'", "if [ -n \"${BASH_VERSION:-}\" ] && [ -x '/usr/local/bin/mise' ]; then", `eval "$(/usr/local/bin/mise activate bash)"`, `if ! grep -Fqx 'eval "$(/usr/local/bin/mise activate bash)"' '/etc/bash.bashrc'; then`, + "cat > /etc/systemd/system/banger-opencode.service <<'EOF'", + "RequiresMountsFor=/root", + "ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", + "systemctl enable --now banger-opencode.service || true", `git clone --depth 1 'https://github.com/tmux-plugins/tpm' "$TMUX_PLUGIN_DIR/tpm"`, `git clone --depth 1 'https://github.com/tmux-plugins/tmux-resurrect' "$TMUX_PLUGIN_DIR/tmux-resurrect"`, `git clone --depth 1 'https://github.com/tmux-plugins/tmux-continuum' "$TMUX_PLUGIN_DIR/tmux-continuum"`, diff --git a/internal/daemon/images.go b/internal/daemon/images.go index 6efdc9e..d24aa9c 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -103,26 +103,33 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i _ = os.RemoveAll(artifactDir) return model.Image{}, err } + seededSSHPublicKeyFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) + if err != nil { + _ = logFile.Sync() + _ = os.RemoveAll(artifactDir) + return model.Image{}, err + } if err := writePackagesMetadata(rootfsPath, d.config.DefaultPackagesFile); err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) return model.Image{}, err } image = model.Image{ - ID: id, - Name: name, - Managed: true, - ArtifactDir: artifactDir, - RootfsPath: rootfsPath, - WorkSeedPath: workSeedPath, - KernelPath: kernelPath, - InitrdPath: initrdPath, - ModulesDir: modulesDir, - PackagesPath: d.config.DefaultPackagesFile, - BuildSize: params.Size, - Docker: params.Docker, - CreatedAt: now, - UpdatedAt: now, + ID: id, + Name: name, + Managed: true, + ArtifactDir: artifactDir, + RootfsPath: rootfsPath, + WorkSeedPath: workSeedPath, + KernelPath: kernelPath, + InitrdPath: initrdPath, + ModulesDir: modulesDir, + PackagesPath: d.config.DefaultPackagesFile, + BuildSize: params.Size, + SeededSSHPublicKeyFingerprint: seededSSHPublicKeyFingerprint, + Docker: params.Docker, + CreatedAt: now, + UpdatedAt: now, } if err := d.store.UpsertImage(ctx, image); err != nil { return model.Image{}, err @@ -220,6 +227,105 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara return image, nil } +func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model.Image, err error) { + d.mu.Lock() + defer d.mu.Unlock() + + op := d.beginOperation("image.promote") + defer func() { + if err != nil { + op.fail(err, imageLogAttrs(image)...) + return + } + op.done(imageLogAttrs(image)...) + }() + + image, err = d.FindImage(ctx, idOrName) + if err != nil { + return model.Image{}, err + } + if image.Managed { + return model.Image{}, fmt.Errorf("image %s is already managed", image.Name) + } + if err := validateImagePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir, image.PackagesPath); err != nil { + return model.Image{}, err + } + if strings.TrimSpace(d.layout.ImagesDir) == "" { + return model.Image{}, errors.New("images dir is not configured") + } + if err := os.MkdirAll(d.layout.ImagesDir, 0o755); err != nil { + return model.Image{}, err + } + + artifactDir := filepath.Join(d.layout.ImagesDir, image.ID) + if _, statErr := os.Stat(artifactDir); statErr == nil { + return model.Image{}, fmt.Errorf("artifact dir already exists: %s", artifactDir) + } else if !os.IsNotExist(statErr) { + return model.Image{}, statErr + } + + stageDir, err := os.MkdirTemp(d.layout.ImagesDir, image.ID+".promote-") + if err != nil { + return model.Image{}, err + } + cleanupStage := true + defer func() { + if cleanupStage { + _ = os.RemoveAll(stageDir) + } + }() + + rootfsPath := filepath.Join(stageDir, "rootfs.ext4") + op.stage("copy_rootfs", "source_rootfs_path", image.RootfsPath, "target_rootfs_path", rootfsPath) + if err := system.CopyFilePreferClone(image.RootfsPath, rootfsPath); err != nil { + return model.Image{}, err + } + + workSeedPath := "" + if image.WorkSeedPath != "" { + if _, statErr := os.Stat(image.WorkSeedPath); statErr != nil { + if os.IsNotExist(statErr) { + op.stage("skip_missing_work_seed", "source_work_seed_path", image.WorkSeedPath) + image.WorkSeedPath = "" + } else { + return model.Image{}, statErr + } + } + } + if image.WorkSeedPath != "" { + workSeedPath = filepath.Join(stageDir, "work-seed.ext4") + op.stage("copy_work_seed", "source_work_seed_path", image.WorkSeedPath, "target_work_seed_path", workSeedPath) + if err := system.CopyFilePreferClone(image.WorkSeedPath, workSeedPath); err != nil { + return model.Image{}, err + } + image.SeededSSHPublicKeyFingerprint, err = d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) + if err != nil { + return model.Image{}, err + } + } else { + image.SeededSSHPublicKeyFingerprint = "" + } + + op.stage("activate_artifacts", "artifact_dir", artifactDir) + if err := os.Rename(stageDir, artifactDir); err != nil { + return model.Image{}, err + } + cleanupStage = false + + image.Managed = true + image.ArtifactDir = artifactDir + image.RootfsPath = filepath.Join(artifactDir, "rootfs.ext4") + if workSeedPath != "" { + image.WorkSeedPath = filepath.Join(artifactDir, "work-seed.ext4") + } + image.UpdatedAt = model.Now() + if err := d.store.UpsertImage(ctx, image); err != nil { + _ = os.RemoveAll(artifactDir) + return model.Image{}, err + } + return image, nil +} + func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath string) error { checks := system.NewPreflight() checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs `) @@ -239,6 +345,22 @@ func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath return checks.Err("image register failed") } +func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir, packagesPath string) error { + checks := system.NewPreflight() + checks.RequireFile(rootfsPath, "rootfs image", `re-register the image with a valid rootfs`) + checks.RequireFile(kernelPath, "kernel image", `re-register the image with a valid kernel`) + if initrdPath != "" { + checks.RequireFile(initrdPath, "initrd image", `re-register the image with a valid initrd`) + } + if modulesDir != "" { + checks.RequireDir(modulesDir, "kernel modules dir", `re-register the image with a valid modules dir`) + } + if packagesPath != "" { + checks.RequireFile(packagesPath, "packages manifest", `re-register the image with a valid packages manifest`) + } + return checks.Err("image promote failed") +} + func writePackagesMetadata(rootfsPath, packagesPath string) error { if rootfsPath == "" || packagesPath == "" { return nil diff --git a/internal/daemon/opencode.go b/internal/daemon/opencode.go new file mode 100644 index 0000000..791a5e4 --- /dev/null +++ b/internal/daemon/opencode.go @@ -0,0 +1,18 @@ +package daemon + +import ( + "context" + + "banger/internal/model" + "banger/internal/opencode" +) + +type opencodeCapability struct{} + +func (opencodeCapability) Name() string { return "opencode" } + +func (opencodeCapability) PostStart(ctx context.Context, d *Daemon, vm model.VMRecord, _ model.Image) error { + return opencode.WaitReady(ctx, d.logger, vm.Runtime.VSockPath, func(stage, detail string) { + vmCreateStage(ctx, stage, detail) + }) +} diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index a0507e5..a602f1a 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -49,10 +49,12 @@ func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm mo if imageName == "" { imageName = d.config.DefaultImageName } + vmCreateStage(ctx, "resolve_image", "resolving image") image, err := d.FindImage(ctx, imageName) if err != nil { return model.VMRecord{}, err } + vmCreateStage(ctx, "resolve_image", "using image "+image.Name) op.stage("image_resolved", imageLogAttrs(image)...) name := strings.TrimSpace(params.Name) if name == "" { @@ -126,6 +128,8 @@ func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm mo MetricsPath: filepath.Join(vmDir, "metrics.json"), }, } + vmCreateBindVM(ctx, vm) + vmCreateStage(ctx, "reserve_vm", fmt.Sprintf("allocated %s (%s)", vm.Name, vm.Runtime.GuestIP)) if err := d.store.UpsertVM(ctx, vm); err != nil { return model.VMRecord{}, err } @@ -168,6 +172,7 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod op.done(vmLogAttrs(vm)...) }() op.stage("preflight") + vmCreateStage(ctx, "preflight", "checking host prerequisites") if err := d.validateStartPrereqs(ctx, vm, image); err != nil { return model.VMRecord{}, err } @@ -209,11 +214,13 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod } op.stage("system_overlay", "overlay_path", vm.Runtime.SystemOverlay) + vmCreateStage(ctx, "prepare_rootfs", "preparing system overlay") if err := d.ensureSystemOverlay(ctx, &vm); err != nil { return model.VMRecord{}, err } op.stage("dm_snapshot", "dm_name", dmName) + vmCreateStage(ctx, "prepare_rootfs", "creating root filesystem snapshot") handles, err := d.createDMSnapshot(ctx, image.RootfsPath, vm.Runtime.SystemOverlay, dmName) if err != nil { return model.VMRecord{}, err @@ -241,10 +248,12 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod } op.stage("patch_root_overlay") + vmCreateStage(ctx, "prepare_rootfs", "writing guest configuration") if err := d.patchRootOverlay(ctx, vm, image); err != nil { return cleanupOnErr(err) } op.stage("prepare_host_features") + vmCreateStage(ctx, "prepare_host_features", "preparing host-side vm features") if err := d.prepareCapabilityHosts(ctx, &vm, image); err != nil { return cleanupOnErr(err) } @@ -265,6 +274,7 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod return cleanupOnErr(err) } op.stage("firecracker_launch", "log_path", vm.Runtime.LogPath, "metrics_path", vm.Runtime.MetricsPath) + vmCreateStage(ctx, "boot_firecracker", "starting firecracker") firecrackerCtx := context.Background() machineConfig := firecracker.MachineConfig{ BinaryPath: fcPath, @@ -304,15 +314,18 @@ func (d *Daemon) startVMLocked(ctx context.Context, vm model.VMRecord, image mod return cleanupOnErr(err) } op.stage("vsock_access", "vsock_path", vm.Runtime.VSockPath, "vsock_cid", vm.Runtime.VSockCID) + vmCreateStage(ctx, "wait_vsock_agent", "waiting for guest vsock agent") if err := d.ensureSocketAccess(ctx, vm.Runtime.VSockPath, "firecracker vsock socket"); err != nil { return cleanupOnErr(err) } op.stage("post_start_features") + vmCreateStage(ctx, "wait_guest_ready", "waiting for guest services") if err := d.postStartCapabilities(ctx, vm, image); err != nil { return cleanupOnErr(err) } system.TouchNow(&vm) op.stage("persist") + vmCreateStage(ctx, "finalize", "saving vm state") if err := d.store.UpsertVM(ctx, vm); err != nil { return cleanupOnErr(err) } @@ -777,58 +790,75 @@ func (d *Daemon) patchRootOverlay(ctx context.Context, vm model.VMRecord, image return nil } -func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image) error { +type workDiskPreparation struct { + ClonedFromSeed bool +} + +func (d *Daemon) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image) (workDiskPreparation, error) { if exists(vm.Runtime.WorkDiskPath) { - return nil + return workDiskPreparation{}, nil } if exists(image.WorkSeedPath) { + vmCreateStage(ctx, "prepare_work_disk", "cloning work seed") if err := system.CopyFilePreferClone(image.WorkSeedPath, vm.Runtime.WorkDiskPath); err != nil { - return err + return workDiskPreparation{}, err } seedInfo, err := os.Stat(image.WorkSeedPath) if err != nil { - return err + return workDiskPreparation{}, err } if vm.Spec.WorkDiskSizeBytes < seedInfo.Size() { - return fmt.Errorf("requested work disk size %d is smaller than seed image %d", vm.Spec.WorkDiskSizeBytes, seedInfo.Size()) + return workDiskPreparation{}, fmt.Errorf("requested work disk size %d is smaller than seed image %d", vm.Spec.WorkDiskSizeBytes, seedInfo.Size()) } if vm.Spec.WorkDiskSizeBytes > seedInfo.Size() { + vmCreateStage(ctx, "prepare_work_disk", "resizing work disk") if err := system.ResizeExt4Image(ctx, d.runner, vm.Runtime.WorkDiskPath, vm.Spec.WorkDiskSizeBytes); err != nil { - return err + return workDiskPreparation{}, err } } - return nil + return workDiskPreparation{ClonedFromSeed: true}, nil } + vmCreateStage(ctx, "prepare_work_disk", "creating empty work disk") if _, err := d.runner.Run(ctx, "truncate", "-s", strconv.FormatInt(vm.Spec.WorkDiskSizeBytes, 10), vm.Runtime.WorkDiskPath); err != nil { - return err + return workDiskPreparation{}, err } if _, err := d.runner.Run(ctx, "mkfs.ext4", "-F", vm.Runtime.WorkDiskPath); err != nil { - return err + return workDiskPreparation{}, err } rootMount, cleanupRoot, err := system.MountTempDir(ctx, d.runner, vm.Runtime.DMDev, true) if err != nil { - return err + return workDiskPreparation{}, err } defer cleanupRoot() workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false) if err != nil { - return err + return workDiskPreparation{}, err } defer cleanupWork() + vmCreateStage(ctx, "prepare_work_disk", "copying /root into work disk") if err := system.CopyDirContents(ctx, d.runner, filepath.Join(rootMount, "root"), workMount, true); err != nil { - return err + return workDiskPreparation{}, err } if err := d.flattenNestedWorkHome(ctx, workMount); err != nil { - return err + return workDiskPreparation{}, err } - return nil + return workDiskPreparation{}, nil } -func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { +func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image, prep workDiskPreparation) error { + fingerprint, err := guest.AuthorizedPublicKeyFingerprint(d.config.SSHKeyPath) + if err != nil { + return fmt.Errorf("derive authorized ssh key fingerprint: %w", err) + } + if prep.ClonedFromSeed && image.SeededSSHPublicKeyFingerprint != "" && image.SeededSSHPublicKeyFingerprint == fingerprint { + vmCreateStage(ctx, "prepare_work_disk", "using seeded SSH access") + return nil + } publicKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) if err != nil { return fmt.Errorf("derive authorized ssh key: %w", err) } + vmCreateStage(ctx, "prepare_work_disk", "repairing SSH access on work disk") workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false) if err != nil { return err @@ -873,6 +903,12 @@ func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VM if _, err := d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil { return err } + if prep.ClonedFromSeed && image.Managed { + vmCreateStage(ctx, "prepare_work_disk", "refreshing managed work seed") + if err := d.refreshManagedWorkSeedFingerprint(ctx, image, fingerprint); err != nil { + return err + } + } return nil } diff --git a/internal/daemon/vm_create_ops.go b/internal/daemon/vm_create_ops.go new file mode 100644 index 0000000..0b856a3 --- /dev/null +++ b/internal/daemon/vm_create_ops.go @@ -0,0 +1,205 @@ +package daemon + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "banger/internal/api" + "banger/internal/model" +) + +type vmCreateProgressKey struct{} + +type vmCreateOperationState struct { + mu sync.Mutex + cancel context.CancelFunc + op api.VMCreateOperation +} + +func newVMCreateOperationState() (*vmCreateOperationState, error) { + id, err := model.NewID() + if err != nil { + return nil, err + } + now := model.Now() + return &vmCreateOperationState{ + op: api.VMCreateOperation{ + ID: id, + Stage: "queued", + Detail: "waiting to start", + StartedAt: now, + UpdatedAt: now, + }, + }, nil +} + +func withVMCreateProgress(ctx context.Context, op *vmCreateOperationState) context.Context { + if op == nil { + return ctx + } + return context.WithValue(ctx, vmCreateProgressKey{}, op) +} + +func vmCreateProgressFromContext(ctx context.Context) *vmCreateOperationState { + if ctx == nil { + return nil + } + op, _ := ctx.Value(vmCreateProgressKey{}).(*vmCreateOperationState) + return op +} + +func vmCreateStage(ctx context.Context, stage, detail string) { + if op := vmCreateProgressFromContext(ctx); op != nil { + op.stage(stage, detail) + } +} + +func vmCreateBindVM(ctx context.Context, vm model.VMRecord) { + if op := vmCreateProgressFromContext(ctx); op != nil { + op.bindVM(vm) + } +} + +func (op *vmCreateOperationState) setCancel(cancel context.CancelFunc) { + op.mu.Lock() + defer op.mu.Unlock() + op.cancel = cancel +} + +func (op *vmCreateOperationState) bindVM(vm model.VMRecord) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.VMID = vm.ID + op.op.VMName = vm.Name +} + +func (op *vmCreateOperationState) stage(stage, detail string) { + op.mu.Lock() + defer op.mu.Unlock() + stage = strings.TrimSpace(stage) + detail = strings.TrimSpace(detail) + if stage == "" { + stage = op.op.Stage + } + if stage == op.op.Stage && detail == op.op.Detail { + return + } + op.op.Stage = stage + op.op.Detail = detail + op.op.UpdatedAt = model.Now() +} + +func (op *vmCreateOperationState) done(vm model.VMRecord) { + op.mu.Lock() + defer op.mu.Unlock() + vmCopy := vm + op.op.VMID = vm.ID + op.op.VMName = vm.Name + op.op.Stage = "ready" + op.op.Detail = "vm is ready" + op.op.Done = true + op.op.Success = true + op.op.Error = "" + op.op.VM = &vmCopy + op.op.UpdatedAt = model.Now() +} + +func (op *vmCreateOperationState) fail(err error) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.Done = true + op.op.Success = false + if err != nil { + op.op.Error = err.Error() + } + if strings.TrimSpace(op.op.Detail) == "" { + op.op.Detail = "vm create failed" + } + op.op.UpdatedAt = model.Now() +} + +func (op *vmCreateOperationState) snapshot() api.VMCreateOperation { + op.mu.Lock() + defer op.mu.Unlock() + snapshot := op.op + if snapshot.VM != nil { + vmCopy := *snapshot.VM + snapshot.VM = &vmCopy + } + return snapshot +} + +func (op *vmCreateOperationState) cancelOperation() { + op.mu.Lock() + cancel := op.cancel + op.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (d *Daemon) BeginVMCreate(_ context.Context, params api.VMCreateParams) (api.VMCreateOperation, error) { + op, err := newVMCreateOperationState() + if err != nil { + return api.VMCreateOperation{}, err + } + createCtx, cancel := context.WithCancel(context.Background()) + op.setCancel(cancel) + + d.createOpsMu.Lock() + if d.createOps == nil { + d.createOps = map[string]*vmCreateOperationState{} + } + d.createOps[op.op.ID] = op + d.createOpsMu.Unlock() + + go d.runVMCreateOperation(withVMCreateProgress(createCtx, op), op, params) + return op.snapshot(), nil +} + +func (d *Daemon) runVMCreateOperation(ctx context.Context, op *vmCreateOperationState, params api.VMCreateParams) { + vm, err := d.CreateVM(ctx, params) + if err != nil { + op.fail(err) + return + } + op.done(vm) +} + +func (d *Daemon) VMCreateStatus(_ context.Context, id string) (api.VMCreateOperation, error) { + d.createOpsMu.Lock() + op, ok := d.createOps[strings.TrimSpace(id)] + d.createOpsMu.Unlock() + if !ok { + return api.VMCreateOperation{}, fmt.Errorf("vm create operation not found: %s", id) + } + return op.snapshot(), nil +} + +func (d *Daemon) CancelVMCreate(_ context.Context, id string) error { + d.createOpsMu.Lock() + op, ok := d.createOps[strings.TrimSpace(id)] + d.createOpsMu.Unlock() + if !ok { + return fmt.Errorf("vm create operation not found: %s", id) + } + op.cancelOperation() + return nil +} + +func (d *Daemon) pruneVMCreateOperations(olderThan time.Time) { + d.createOpsMu.Lock() + defer d.createOpsMu.Unlock() + for id, op := range d.createOps { + snapshot := op.snapshot() + if !snapshot.Done { + continue + } + if snapshot.UpdatedAt.Before(olderThan) { + delete(d.createOps, id) + } + } +} diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 893c216..3298a69 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -716,7 +716,7 @@ func TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout(t *testing.T) { vm := testVM("seed-repair", "image-seed-repair", "172.16.0.61") vm.Runtime.WorkDiskPath = workDiskDir - if err := d.ensureAuthorizedKeyOnWorkDisk(context.Background(), &vm); err != nil { + if err := d.ensureAuthorizedKeyOnWorkDisk(context.Background(), &vm, model.Image{}, workDiskPreparation{}); err != nil { t.Fatalf("ensureAuthorizedKeyOnWorkDisk: %v", err) } if _, err := os.Stat(filepath.Join(workDiskDir, "root")); !os.IsNotExist(err) { @@ -748,6 +748,61 @@ func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) { } } +func TestBeginVMCreateCompletesAndReturnsStatus(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db := openDaemonStore(t) + image := testImage("default") + image.ID = "default-image-id" + image.Name = "default" + if err := db.UpsertImage(ctx, image); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + + d := &Daemon{ + store: db, + layout: paths.Layout{ + VMsDir: t.TempDir(), + }, + config: model.DaemonConfig{ + DefaultImageName: image.Name, + BridgeIP: model.DefaultBridgeIP, + }, + } + + op, err := d.BeginVMCreate(ctx, api.VMCreateParams{Name: "queued", NoStart: true}) + if err != nil { + t.Fatalf("BeginVMCreate: %v", err) + } + if op.ID == "" { + t.Fatal("operation id should be populated") + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + status, err := d.VMCreateStatus(ctx, op.ID) + if err != nil { + t.Fatalf("VMCreateStatus: %v", err) + } + if !status.Done { + time.Sleep(10 * time.Millisecond) + continue + } + if !status.Success { + t.Fatalf("status = %+v, want success", status) + } + if status.VM == nil || status.VM.Name != "queued" { + t.Fatalf("status VM = %+v, want queued vm", status.VM) + } + if status.VM.State != model.VMStateStopped { + t.Fatalf("status VM state = %s, want stopped", status.VM.State) + } + return + } + t.Fatal("vm create operation did not finish before timeout") +} + func TestCreateVMUsesDefaultsWhenCPUAndMemoryOmitted(t *testing.T) { ctx := context.Background() db := openDaemonStore(t) diff --git a/internal/guest/ssh.go b/internal/guest/ssh.go index 01829d0..3422da2 100644 --- a/internal/guest/ssh.go +++ b/internal/guest/ssh.go @@ -4,6 +4,8 @@ import ( "archive/tar" "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -137,6 +139,15 @@ func AuthorizedPublicKey(path string) ([]byte, error) { return ssh.MarshalAuthorizedKey(signer.PublicKey()), nil } +func AuthorizedPublicKeyFingerprint(path string) (string, error) { + key, err := AuthorizedPublicKey(path) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(strings.TrimSpace(string(key)))) + return hex.EncodeToString(sum[:]), nil +} + func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } diff --git a/internal/guestnet/assets/bootstrap.sh b/internal/guestnet/assets/bootstrap.sh new file mode 100644 index 0000000..38a75ec --- /dev/null +++ b/internal/guestnet/assets/bootstrap.sh @@ -0,0 +1,132 @@ +#!/bin/sh +set -eu + +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +if ! command -v ip >/dev/null 2>&1; then + exit 0 +fi + +cmdline="$(cat /proc/cmdline 2>/dev/null || true)" +ip_arg="" +for arg in $cmdline; do + case "$arg" in + ip=*) + ip_arg="${arg#ip=}" + break + ;; + esac +done + +if [ -z "$ip_arg" ]; then + exit 0 +fi + +field() { + printf '%s' "$ip_arg" | cut -d: -f"$1" +} + +mask_to_prefix() { + case "$1" in + [0-9]|[1-2][0-9]|3[0-2]) + printf '%s\n' "$1" + return 0 + ;; + esac + + prefix=0 + old_ifs=$IFS + IFS=. + set -- $1 + IFS=$old_ifs + if [ "$#" -ne 4 ]; then + return 1 + fi + + for octet in "$@"; do + case "$octet" in + 255) prefix=$((prefix + 8)) ;; + 254) prefix=$((prefix + 7)) ;; + 252) prefix=$((prefix + 6)) ;; + 248) prefix=$((prefix + 5)) ;; + 240) prefix=$((prefix + 4)) ;; + 224) prefix=$((prefix + 3)) ;; + 192) prefix=$((prefix + 2)) ;; + 128) prefix=$((prefix + 1)) ;; + 0) ;; + *) return 1 ;; + esac + done + + printf '%s\n' "$prefix" +} + +find_iface() { + hint="$1" + if [ -n "$hint" ] && [ -d "/sys/class/net/$hint" ]; then + printf '%s\n' "$hint" + return 0 + fi + + for path in /sys/class/net/*; do + [ -e "$path" ] || continue + iface="${path##*/}" + if [ "$iface" = "lo" ]; then + continue + fi + printf '%s\n' "$iface" + return 0 + done + + return 1 +} + +guest_ip="$(field 1)" +gateway_ip="$(field 3)" +netmask="$(field 4)" +iface_hint="$(field 6)" +dns1="$(field 8)" +dns2="$(field 9)" + +if [ -z "$guest_ip" ]; then + exit 0 +fi + +iface="" +attempt=0 +while [ "$attempt" -lt 50 ]; do + iface="$(find_iface "$iface_hint" || true)" + if [ -n "$iface" ]; then + break + fi + attempt=$((attempt + 1)) + sleep 0.2 +done + +if [ -z "$iface" ]; then + exit 0 +fi + +prefix="$(mask_to_prefix "$netmask" || printf '24\n')" + +ip link set "$iface" up +ip addr replace "$guest_ip/$prefix" dev "$iface" + +if [ -n "$gateway_ip" ]; then + ip route replace default via "$gateway_ip" dev "$iface" +fi + +if [ -n "$dns1" ] || [ -n "$dns2" ]; then + tmp_resolv="/tmp/.banger-resolv.conf.$$" + : > "$tmp_resolv" + if [ -n "$dns1" ]; then + printf 'nameserver %s\n' "$dns1" >> "$tmp_resolv" + fi + if [ -n "$dns2" ]; then + printf 'nameserver %s\n' "$dns2" >> "$tmp_resolv" + fi + if [ -s "$tmp_resolv" ]; then + cat "$tmp_resolv" > /etc/resolv.conf + fi + rm -f "$tmp_resolv" +fi diff --git a/internal/guestnet/assets/systemd.service b/internal/guestnet/assets/systemd.service new file mode 100644 index 0000000..8dcc4d0 --- /dev/null +++ b/internal/guestnet/assets/systemd.service @@ -0,0 +1,13 @@ +[Unit] +Description=Banger guest network bootstrap +After=local-fs.target +Before=network.target network-online.target +ConditionPathExists=/proc/cmdline + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/banger-network-bootstrap +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/internal/guestnet/assets/void-core-service.sh b/internal/guestnet/assets/void-core-service.sh new file mode 100644 index 0000000..82f5827 --- /dev/null +++ b/internal/guestnet/assets/void-core-service.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if [ -x /usr/local/libexec/banger-network-bootstrap ]; then + /usr/local/libexec/banger-network-bootstrap +fi diff --git a/internal/guestnet/guestnet.go b/internal/guestnet/guestnet.go new file mode 100644 index 0000000..d4dfc6f --- /dev/null +++ b/internal/guestnet/guestnet.go @@ -0,0 +1,30 @@ +package guestnet + +import _ "embed" + +const ( + GuestScriptPath = "/usr/local/libexec/banger-network-bootstrap" + SystemdServiceName = "banger-network.service" + VoidCoreServicePath = "/etc/runit/core-services/20-banger-network.sh" +) + +var ( + //go:embed assets/bootstrap.sh + bootstrapScript string + //go:embed assets/systemd.service + systemdService string + //go:embed assets/void-core-service.sh + voidCoreService string +) + +func BootstrapScript() string { + return bootstrapScript +} + +func SystemdServiceUnit() string { + return systemdService +} + +func VoidCoreService() string { + return voidCoreService +} diff --git a/internal/model/types.go b/internal/model/types.go index aabbc4f..400019f 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -61,20 +61,21 @@ type DaemonConfig struct { } 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"` - WorkSeedPath string `json:"work_seed_path,omitempty"` - 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"` + ID string `json:"id"` + Name string `json:"name"` + Managed bool `json:"managed"` + ArtifactDir string `json:"artifact_dir,omitempty"` + RootfsPath string `json:"rootfs_path"` + WorkSeedPath string `json:"work_seed_path,omitempty"` + 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"` + SeededSSHPublicKeyFingerprint string `json:"seeded_ssh_public_key_fingerprint,omitempty"` + Docker bool `json:"docker"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type VMSpec struct { diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go new file mode 100644 index 0000000..e5d75c6 --- /dev/null +++ b/internal/opencode/opencode.go @@ -0,0 +1,104 @@ +package opencode + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "banger/internal/vsockagent" +) + +const ( + Port = 4096 + Host = "0.0.0.0" + GuestBinaryPath = "/usr/local/bin/opencode" + ShimPath = "/root/.local/share/mise/shims/opencode" + ServiceName = "banger-opencode.service" + RunitServiceName = "banger-opencode" + ReadyTimeout = 15 * time.Second + pollInterval = 200 * time.Millisecond +) + +func ServiceUnit() string { + return fmt.Sprintf(`[Unit] +Description=Banger opencode server +After=network.target +RequiresMountsFor=/root + +[Service] +Type=simple +Environment=HOME=/root +WorkingDirectory=/root +ExecStart=%s serve --hostname %s --port %d +Restart=on-failure +RestartSec=1 + +[Install] +WantedBy=multi-user.target +`, GuestBinaryPath, Host, Port) +} + +func RunitRunScript() string { + return fmt.Sprintf(`#!/bin/sh +set -e +export HOME=/root +cd /root +exec %s serve --hostname %s --port %d +`, GuestBinaryPath, Host, Port) +} + +func Ready(listeners []vsockagent.PortListener) bool { + for _, listener := range listeners { + if strings.ToLower(strings.TrimSpace(listener.Proto)) != "tcp" { + continue + } + if listener.Port == Port { + return true + } + } + return false +} + +func WaitReady(ctx context.Context, logger *slog.Logger, socketPath string, report func(stage, detail string)) error { + return waitReady(ctx, logger, socketPath, ReadyTimeout, report) +} + +func waitReady(ctx context.Context, logger *slog.Logger, socketPath string, timeout time.Duration, report func(stage, detail string)) error { + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + var lastErr error + for { + portsCtx, portsCancel := context.WithTimeout(waitCtx, 3*time.Second) + listeners, err := vsockagent.Ports(portsCtx, logger, socketPath) + portsCancel() + if err == nil { + if Ready(listeners) { + return nil + } + if report != nil { + report("wait_opencode", fmt.Sprintf("waiting for opencode on guest port %d", Port)) + } + lastErr = fmt.Errorf("guest port %d is not listening yet", Port) + } else { + if report != nil { + report("wait_vsock_agent", "waiting for guest vsock agent") + } + lastErr = err + } + + select { + case <-waitCtx.Done(): + if lastErr != nil { + return fmt.Errorf("opencode server did not become ready on guest port %d: %w", Port, lastErr) + } + return fmt.Errorf("opencode server did not become ready on guest port %d before timeout", Port) + case <-ticker.C: + } + } +} diff --git a/internal/opencode/opencode_test.go b/internal/opencode/opencode_test.go new file mode 100644 index 0000000..e0ecdb9 --- /dev/null +++ b/internal/opencode/opencode_test.go @@ -0,0 +1,116 @@ +package opencode + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "banger/internal/vsockagent" +) + +func TestServiceUnitContainsExpectedExecStart(t *testing.T) { + unit := ServiceUnit() + for _, snippet := range []string{ + "RequiresMountsFor=/root", + "WorkingDirectory=/root", + "Environment=HOME=/root", + "ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", + "WantedBy=multi-user.target", + } { + if !strings.Contains(unit, snippet) { + t.Fatalf("service unit missing snippet %q\nunit:\n%s", snippet, unit) + } + } +} + +func TestRunitRunScriptContainsExpectedExec(t *testing.T) { + script := RunitRunScript() + for _, snippet := range []string{ + "export HOME=/root", + "cd /root", + "exec /usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", + } { + if !strings.Contains(script, snippet) { + t.Fatalf("runit script missing snippet %q\nscript:\n%s", snippet, script) + } + } +} + +func TestReadyMatchesTCPPort(t *testing.T) { + if Ready([]vsockagent.PortListener{{Proto: "udp", Port: Port}}) { + t.Fatal("udp listener should not satisfy readiness") + } + if Ready([]vsockagent.PortListener{{Proto: "tcp", Port: 8080}}) { + t.Fatal("wrong tcp port should not satisfy readiness") + } + if !Ready([]vsockagent.PortListener{{Proto: "tcp", Port: Port}}) { + t.Fatal("tcp listener on opencode port should satisfy readiness") + } +} + +func TestWaitReadyReturnsWhenPortIsListening(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "opencode.vsock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + _ = os.Remove(socketPath) + }) + + serverDone := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverDone <- err + return + } + defer conn.Close() + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil { + serverDone <- err + return + } + if got := string(buf[:n]); got != "CONNECT 42070\n" { + serverDone <- fmt.Errorf("unexpected connect message %q", got) + return + } + if _, err := conn.Write([]byte("OK 1\n")); err != nil { + serverDone <- err + return + } + reqBuf := make([]byte, 0, 512) + for { + n, err = conn.Read(buf) + if err != nil { + serverDone <- err + return + } + reqBuf = append(reqBuf, buf[:n]...) + if strings.Contains(string(reqBuf), "\r\n\r\n") { + break + } + } + if !strings.Contains(string(reqBuf), "GET /ports HTTP/1.1\r\n") { + serverDone <- fmt.Errorf("unexpected ports payload %q", string(reqBuf)) + return + } + body := []byte(`{"listeners":[{"proto":"tcp","bind_address":"0.0.0.0","port":4096}]}`) + _, err = conn.Write([]byte(fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body))) + serverDone <- err + }() + + if err := waitReady(context.Background(), nil, socketPath, time.Second, nil); err != nil { + t.Fatalf("waitReady: %v", err) + } + if err := <-serverDone; err != nil { + t.Fatalf("server: %v", err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 0a55e3c..f15ebfc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -80,6 +80,7 @@ func (s *Store) migrate() error { modules_dir TEXT, packages_path TEXT, build_size TEXT, + seeded_ssh_public_key_fingerprint TEXT, docker INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -107,6 +108,9 @@ func (s *Store) migrate() error { if err := ensureColumnExists(s.db, "images", "work_seed_path", "TEXT"); err != nil { return err } + if err := ensureColumnExists(s.db, "images", "seeded_ssh_public_key_fingerprint", "TEXT"); err != nil { + return err + } return nil } @@ -116,8 +120,8 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { const query = ` INSERT INTO images ( id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, - modules_dir, packages_path, build_size, docker, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, managed=excluded.managed, @@ -129,6 +133,7 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { modules_dir=excluded.modules_dir, packages_path=excluded.packages_path, build_size=excluded.build_size, + seeded_ssh_public_key_fingerprint=excluded.seeded_ssh_public_key_fingerprint, docker=excluded.docker, updated_at=excluded.updated_at` _, err := s.db.ExecContext(ctx, query, @@ -143,6 +148,7 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { image.ModulesDir, image.PackagesPath, image.BuildSize, + image.SeededSSHPublicKeyFingerprint, boolToInt(image.Docker), image.CreatedAt.Format(time.RFC3339), image.UpdatedAt.Format(time.RFC3339), @@ -151,15 +157,15 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { } func (s *Store) GetImageByName(ctx context.Context, name string) (model.Image, error) { - return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE name = ?", name) + return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, 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, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images WHERE id = ?", id) + return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, 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, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, docker, created_at, updated_at FROM images ORDER BY created_at ASC") + rows, err := s.db.QueryContext(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at FROM images ORDER BY created_at ASC") if err != nil { return nil, err } @@ -337,6 +343,7 @@ func scanImageRow(row scanner) (model.Image, error) { var image model.Image var managed, docker int var workSeedPath sql.NullString + var seededSSHPublicKeyFingerprint sql.NullString var createdAt, updatedAt string err := row.Scan( &image.ID, @@ -350,6 +357,7 @@ func scanImageRow(row scanner) (model.Image, error) { &image.ModulesDir, &image.PackagesPath, &image.BuildSize, + &seededSSHPublicKeyFingerprint, &docker, &createdAt, &updatedAt, @@ -360,6 +368,7 @@ func scanImageRow(row scanner) (model.Image, error) { image.Managed = managed == 1 image.Docker = docker == 1 image.WorkSeedPath = workSeedPath.String + image.SeededSSHPublicKeyFingerprint = seededSSHPublicKeyFingerprint.String image.CreatedAt, err = time.Parse(time.RFC3339, createdAt) if err != nil { return image, err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index dfbf401..0e7ea2a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -335,20 +335,21 @@ func openTestStore(t *testing.T) *Store { func sampleImage(name string) model.Image { now := fixedTime() return model.Image{ - ID: name + "-id", - Name: name, - Managed: true, - ArtifactDir: "/artifacts/" + name, - RootfsPath: "/images/" + name + ".ext4", - WorkSeedPath: "/images/" + name + ".work-seed.ext4", - KernelPath: "/kernels/" + name, - InitrdPath: "/initrd/" + name, - ModulesDir: "/modules/" + name, - PackagesPath: "/packages/" + name + ".apt", - BuildSize: "8G", - Docker: true, - CreatedAt: now, - UpdatedAt: now, + ID: name + "-id", + Name: name, + Managed: true, + ArtifactDir: "/artifacts/" + name, + RootfsPath: "/images/" + name + ".ext4", + WorkSeedPath: "/images/" + name + ".work-seed.ext4", + KernelPath: "/kernels/" + name, + InitrdPath: "/initrd/" + name, + ModulesDir: "/modules/" + name, + PackagesPath: "/packages/" + name + ".apt", + BuildSize: "8G", + SeededSSHPublicKeyFingerprint: "seeded-fingerprint", + Docker: true, + CreatedAt: now, + UpdatedAt: now, } } diff --git a/internal/system/system.go b/internal/system/system.go index ff63516..f29b464 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -397,9 +397,10 @@ func UpdateFSTab(existing string) string { 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", + "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=%s::%s:255.255.255.0:%s:eth0:off:%s hostname=%s systemd.mask=home.mount systemd.mask=var.mount", guestIP, bridgeIP, + vmName, dns, vmName, ) diff --git a/internal/system/system_test.go b/internal/system/system_test.go index deaa7f1..c72eca5 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -167,6 +167,16 @@ func TestReadNormalizedLines(t *testing.T) { } } +func TestBuildBootArgsIncludesHostnameInIPField(t *testing.T) { + t.Parallel() + + got := BuildBootArgs("devbox", "172.16.0.2", "172.16.0.1", "1.1.1.1") + want := "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=172.16.0.2::172.16.0.1:255.255.255.0:devbox:eth0:off:1.1.1.1 hostname=devbox systemd.mask=home.mount systemd.mask=var.mount" + if got != want { + t.Fatalf("BuildBootArgs() = %q, want %q", got, want) + } +} + func TestWriteExt4FileRemovesTempFileAndReturnsCopyError(t *testing.T) { t.Parallel() diff --git a/make-rootfs-void.sh b/make-rootfs-void.sh index fb21397..1cf5c89 100755 --- a/make-rootfs-void.sh +++ b/make-rootfs-void.sh @@ -18,8 +18,10 @@ Defaults: --arch x86_64 --packages ./packages.void -This path is experimental and local-only. It reuses the current runtime bundle -kernel/initrd/modules and does not change the default Debian image flow. +This path is experimental and local-only. If ./runtime/void-kernel exists it +uses the staged Void kernel modules from that directory; otherwise it falls back +to the current runtime bundle modules. It does not change the default Debian +image flow. EOF } @@ -85,6 +87,14 @@ bundle_path() { printf '%s\n' "$fallback" } +find_latest_module_dir() { + local root="$1" + if [[ ! -d "$root" ]]; then + return 1 + fi + find "$root" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 +} + find_static_binary() { local name="$1" find "$STATIC_DIR" -type f \( -name "$name" -o -name "$name.static" \) -perm -u+x | sort | head -n 1 @@ -94,6 +104,15 @@ find_static_keys_dir() { find "$STATIC_DIR" -type d -path '*/var/db/xbps/keys' | sort | head -n 1 } +install_root_authorized_key() { + local public_key + public_key="$(ssh-keygen -y -f "$SSH_KEY")" + sudo mkdir -p "$ROOT_MOUNT/root/.ssh" + printf '%s\n' "$public_key" | sudo tee "$ROOT_MOUNT/root/.ssh/authorized_keys" >/dev/null + sudo chmod 700 "$ROOT_MOUNT/root/.ssh" + sudo chmod 600 "$ROOT_MOUNT/root/.ssh/authorized_keys" +} + ensure_sshd_include() { local cfg="$ROOT_MOUNT/etc/ssh/sshd_config" local tmp_cfg="$TMP_DIR/sshd_config" @@ -137,6 +156,34 @@ EOF sudo ln -snf /etc/sv/banger-vsock-agent "$ROOT_MOUNT/etc/runit/runsvdir/default/banger-vsock-agent" } +install_opencode_service() { + local service_dir="$ROOT_MOUNT/etc/sv/banger-opencode" + local run_path="$service_dir/run" + local finish_path="$service_dir/finish" + + sudo mkdir -p "$service_dir" + cat <<'EOF' | sudo tee "$run_path" >/dev/null +#!/bin/sh +set -e +export HOME=/root +cd /root +exec /usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096 +EOF + cat <<'EOF' | sudo tee "$finish_path" >/dev/null +#!/bin/sh +exit 0 +EOF + sudo chmod 0755 "$run_path" "$finish_path" + sudo mkdir -p "$ROOT_MOUNT/etc/runit/runsvdir/default" + sudo ln -snf /etc/sv/banger-opencode "$ROOT_MOUNT/etc/runit/runsvdir/default/banger-opencode" +} + +install_guest_network_bootstrap() { + sudo mkdir -p "$ROOT_MOUNT/usr/local/libexec" "$ROOT_MOUNT/etc/runit/core-services" + sudo install -m 0755 "$GUESTNET_BOOTSTRAP_SCRIPT" "$ROOT_MOUNT/usr/local/libexec/banger-network-bootstrap" + sudo install -m 0644 "$GUESTNET_VOID_CORE_SERVICE" "$ROOT_MOUNT/etc/runit/core-services/20-banger-network.sh" +} + configure_docker_bootstrap() { local modules_conf="$ROOT_MOUNT/etc/modules-load.d/docker-netfilter.conf" local sysctl_conf="$ROOT_MOUNT/etc/sysctl.d/99-docker.conf" @@ -346,6 +393,7 @@ if [[ ! -d "$RUNTIME_DIR" ]]; then fi BUNDLE_METADATA="$RUNTIME_DIR/bundle.json" +SSH_KEY="$(bundle_path ssh_key_path "$RUNTIME_DIR/id_ed25519")" OUT_ROOTFS="$RUNTIME_DIR/rootfs-void.ext4" SIZE_SPEC="2G" MIRROR="https://repo-default.voidlinux.org" @@ -353,11 +401,17 @@ ARCH="x86_64" MISE_VERSION="v2025.12.0" MISE_INSTALL_PATH="/usr/local/bin/mise" OPENCODE_TOOL="github:anomalyco/opencode" +GUESTNET_BOOTSTRAP_SCRIPT="$SCRIPT_DIR/internal/guestnet/assets/bootstrap.sh" +GUESTNET_VOID_CORE_SERVICE="$SCRIPT_DIR/internal/guestnet/assets/void-core-service.sh" MODULES_DIR="$(bundle_path default_modules_dir "$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic")" +VOID_KERNEL_MODULES_DIR="$(find_latest_module_dir "$RUNTIME_DIR/void-kernel/lib/modules" || true)" VSOCK_AGENT="$(bundle_path vsock_agent_path "$RUNTIME_DIR/banger-vsock-agent")" if [[ "$VSOCK_AGENT" == "$RUNTIME_DIR/banger-vsock-agent" && ! -x "$VSOCK_AGENT" ]]; then VSOCK_AGENT="$(bundle_path vsock_ping_helper_path "$RUNTIME_DIR/banger-vsock-pingd")" fi +if [[ -n "$VOID_KERNEL_MODULES_DIR" ]]; then + MODULES_DIR="$VOID_KERNEL_MODULES_DIR" +fi while [[ $# -gt 0 ]]; do case "$1" in @@ -417,6 +471,14 @@ if [[ ! -x "$VSOCK_AGENT" ]]; then log "run 'make build' or refresh the runtime bundle" exit 1 fi +if [[ ! -f "$GUESTNET_BOOTSTRAP_SCRIPT" ]]; then + log "guest network bootstrap script not found: $GUESTNET_BOOTSTRAP_SCRIPT" + exit 1 +fi +if [[ ! -f "$GUESTNET_VOID_CORE_SERVICE" ]]; then + log "guest network core-service shim not found: $GUESTNET_VOID_CORE_SERVICE" + exit 1 +fi if [[ -e "$OUT_ROOTFS" ]]; then log "output rootfs already exists: $OUT_ROOTFS" exit 1 @@ -426,6 +488,7 @@ require_command curl require_command tar require_command sudo require_command mkfs.ext4 +require_command ssh-keygen require_command mount require_command umount require_command install @@ -498,7 +561,11 @@ if [[ -n "$XBPS_QUERY" && -x "$XBPS_QUERY" ]]; then sudo env XBPS_ARCH="$ARCH" "$XBPS_QUERY" -r "$ROOT_MOUNT" -l | awk '/^ii/ {print " " $2}' || true fi -log "copying bundled kernel modules into the guest" +if [[ -n "$VOID_KERNEL_MODULES_DIR" ]]; then + log "copying staged Void kernel modules into the guest" +else + log "copying bundled kernel modules into the guest" +fi sudo mkdir -p "$ROOT_MOUNT/lib/modules" sudo cp -a "$MODULES_DIR" "$ROOT_MOUNT/lib/modules/" @@ -507,6 +574,7 @@ sudo mkdir -p "$ROOT_MOUNT/usr/local/bin" sudo install -m 0755 "$VSOCK_AGENT" "$ROOT_MOUNT/usr/local/bin/banger-vsock-agent" log "preparing SSH and runit services" +install_guest_network_bootstrap ensure_sshd_include enable_sshd_service install_vsock_service @@ -516,7 +584,8 @@ normalize_root_shell configure_root_bash_prompt log "installing mise and opencode" install_mise_and_opencode -sudo mkdir -p "$ROOT_MOUNT/root/.ssh" +install_opencode_service +install_root_authorized_key sudo touch "$ROOT_MOUNT/etc/fstab" "$ROOT_MOUNT/etc/hostname" sudo chroot "$ROOT_MOUNT" /usr/bin/ssh-keygen -A diff --git a/make-void-kernel.sh b/make-void-kernel.sh new file mode 100755 index 0000000..fb67ba4 --- /dev/null +++ b/make-void-kernel.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + printf '[make-void-kernel] %s\n' "$*" +} + +usage() { + cat <<'EOF' +Usage: ./make-void-kernel.sh [--out-dir ] [--mirror ] [--arch ] [--kernel-package ] [--print-register-flags] + +Download and stage a Void Linux kernel under ./runtime/void-kernel for the +experimental Void guest flow. + +Defaults: + --out-dir ./runtime/void-kernel + --mirror https://repo-default.voidlinux.org + --arch x86_64 + --kernel-package linux6.12 + +The staged output contains: + boot/vmlinux- Firecracker-usable kernel extracted from vmlinuz + boot/vmlinuz- Raw distro boot image from the Void package + boot/initramfs-.img Matching initramfs generated with dracut + boot/config- Void kernel config + lib/modules// Matching kernel modules tree + +If --print-register-flags is passed, the script does not download anything. It +prints the banger image register flags for an existing staged Void kernel. +EOF +} + +require_command() { + local name="$1" + command -v "$name" >/dev/null 2>&1 || { + log "required command not found: $name" + exit 1 + } +} + +normalize_mirror() { + local mirror="${1%/}" + mirror="${mirror%/current}" + mirror="${mirror%/static}" + printf '%s\n' "$mirror" +} + +find_static_binary() { + local name="$1" + find "$STATIC_DIR" -type f \( -name "$name" -o -name "$name.static" \) -perm -u+x | sort | head -n 1 +} + +find_static_keys_dir() { + find "$STATIC_DIR" -type d -path '*/var/db/xbps/keys' | sort | head -n 1 +} + +find_latest_matching() { + local dir="$1" + local pattern="$2" + if [[ ! -d "$dir" ]]; then + return 1 + fi + find "$dir" -maxdepth 1 -type f -name "$pattern" | sort | tail -n 1 +} + +find_latest_module_dir() { + local root="$1" + if [[ ! -d "$root" ]]; then + return 1 + fi + find "$root" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 +} + +print_register_flags() { + local kernel="" + local initrd="" + local modules="" + + kernel="$(find_latest_matching "$OUT_DIR/boot" 'vmlinux-*' || true)" + initrd="$(find_latest_matching "$OUT_DIR/boot" 'initramfs-*' || true)" + modules="$(find_latest_module_dir "$OUT_DIR/lib/modules" || true)" + + if [[ -z "$kernel" || -z "$modules" ]]; then + log "staged Void kernel not found under $OUT_DIR" + exit 1 + fi + + printf -- '--kernel %q ' "$kernel" + if [[ -n "$initrd" ]]; then + printf -- '--initrd %q ' "$initrd" + fi + printf -- '--modules %q\n' "$modules" +} + +check_elf() { + local path="$1" + readelf -h "$path" >/dev/null 2>&1 +} + +ensure_stage_root_layout() { + mkdir -p "$STAGE_ROOT/usr" + + if [[ ! -e "$STAGE_ROOT/bin" ]]; then + ln -snf usr/bin "$STAGE_ROOT/bin" + fi + if [[ ! -e "$STAGE_ROOT/sbin" ]]; then + ln -snf usr/bin "$STAGE_ROOT/sbin" + fi + if [[ ! -e "$STAGE_ROOT/usr/sbin" ]]; then + ln -snf bin "$STAGE_ROOT/usr/sbin" + fi + if [[ ! -e "$STAGE_ROOT/lib" ]]; then + ln -snf usr/lib "$STAGE_ROOT/lib" + fi + if [[ ! -e "$STAGE_ROOT/lib64" ]]; then + ln -snf usr/lib "$STAGE_ROOT/lib64" + fi + if [[ ! -e "$STAGE_ROOT/usr/lib64" ]]; then + ln -snf lib "$STAGE_ROOT/usr/lib64" + fi + if [[ -x "$STAGE_ROOT/usr/bin/udevd" ]]; then + mkdir -p "$STAGE_ROOT/usr/lib/udev" "$STAGE_ROOT/usr/lib/systemd" + if [[ ! -e "$STAGE_ROOT/usr/lib/udev/udevd" ]]; then + ln -snf ../../bin/udevd "$STAGE_ROOT/usr/lib/udev/udevd" + fi + if [[ ! -e "$STAGE_ROOT/usr/lib/systemd/systemd-udevd" ]]; then + ln -snf ../../bin/udevd "$STAGE_ROOT/usr/lib/systemd/systemd-udevd" + fi + fi +} + +sync_host_dracut_tree() { + if [[ ! -d /usr/lib/dracut ]]; then + log "host dracut support files not found under /usr/lib/dracut" + exit 1 + fi + rm -rf "$STAGE_ROOT/usr/lib/dracut" + mkdir -p "$STAGE_ROOT/usr/lib" + cp -a /usr/lib/dracut "$STAGE_ROOT/usr/lib/dracut" +} + +build_initramfs() { + local kver="$1" + local modules_dir="$2" + local out="$3" + local config_dir="$TMP_DIR/dracut.conf.d" + local tmpdir="$TMP_DIR/dracut-tmp" + local force_drivers="virtio virtio_ring virtio_mmio virtio_blk virtio_net virtio_console ext4 vsock vmw_vsock_virtio_transport" + + mkdir -p "$config_dir" "$tmpdir" + ensure_stage_root_layout + sync_host_dracut_tree + + log "generating initramfs for kernel $kver with host dracut against the staged Void sysroot" + env dracutbasedir="/usr/lib/dracut" dracut \ + --force \ + --kver "$kver" \ + --sysroot "$STAGE_ROOT" \ + --kmoddir "$modules_dir" \ + --conf /dev/null \ + --confdir "$config_dir" \ + --tmpdir "$tmpdir" \ + --no-hostonly \ + --filesystems "ext4" \ + --force-drivers "$force_drivers" \ + --gzip \ + "$out" +} + +extract_vmlinux() { + local image="$1" + local out="$2" + local tmp="$TMP_DIR/vmlinux.extract" + + if check_elf "$image"; then + install -m 0644 "$image" "$out" + return 0 + fi + + try_decompress() { + local header="$1" + local marker="$2" + local command="$3" + local pos="" + + while IFS= read -r pos; do + [[ -n "$pos" ]] || continue + pos="${pos%%:*}" + tail -c+"$pos" "$image" | eval "$command" >"$tmp" 2>/dev/null || true + if check_elf "$tmp"; then + install -m 0644 "$tmp" "$out" + return 0 + fi + done < <(tr "$header\n$marker" "\n$marker=" < "$image" | grep -abo "^$marker" || true) + + return 1 + } + + try_decompress '\037\213\010' "xy" "gunzip" && return 0 + try_decompress '\3757zXZ\000' "abcde" "unxz" && return 0 + try_decompress "BZh" "xy" "bunzip2" && return 0 + try_decompress '\135\000\000\000' "xxx" "unlzma" && return 0 + try_decompress '\002!L\030' "xxx" "lz4 -d" && return 0 + try_decompress '(\265/\375' "xxx" "unzstd" && return 0 + + return 1 +} + +resolve_kernel_package_file() { + local escaped_name="" + escaped_name="$(printf '%s\n' "$KERNEL_PACKAGE" | sed 's/[.[\*^$()+?{|]/\\&/g')" + + curl -fsSL "$REPO_URL/" | + grep -o "${escaped_name}-[0-9][^\" >]*\\.${ARCH}\\.xbps" | + sort -u | + tail -n 1 +} + +cleanup() { + if [[ -n "${TMP_DIR:-}" && -d "${TMP_DIR:-}" ]]; then + rm -rf "$TMP_DIR" + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_RUNTIME_DIR="$SCRIPT_DIR" +if [[ -d "$SCRIPT_DIR/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime" +fi +RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" +OUT_DIR="$RUNTIME_DIR/void-kernel" +MIRROR="https://repo-default.voidlinux.org" +ARCH="x86_64" +KERNEL_PACKAGE="linux6.12" +PRINT_REGISTER_FLAGS=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --out-dir) + OUT_DIR="${2:-}" + shift 2 + ;; + --mirror) + MIRROR="${2:-}" + shift 2 + ;; + --arch) + ARCH="${2:-}" + shift 2 + ;; + --kernel-package) + KERNEL_PACKAGE="${2:-}" + shift 2 + ;; + --print-register-flags) + PRINT_REGISTER_FLAGS=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + log "unknown option: $1" + usage + exit 1 + ;; + esac +done + +MIRROR="$(normalize_mirror "$MIRROR")" +REPO_URL="$MIRROR/current" +STATIC_ARCHIVE_URL="$MIRROR/static/xbps-static-latest.x86_64-musl.tar.xz" + +if [[ "$PRINT_REGISTER_FLAGS" == "1" ]]; then + print_register_flags + exit 0 +fi + +if [[ "$ARCH" != "x86_64" ]]; then + log "unsupported arch: $ARCH" + log "this experimental downloader currently supports only x86_64" + exit 1 +fi +if [[ ! -d "$RUNTIME_DIR" ]]; then + log "runtime bundle not found: $RUNTIME_DIR" + exit 1 +fi +if [[ -e "$OUT_DIR" ]]; then + log "output directory already exists: $OUT_DIR" + log "remove it first if you want to re-stage a different Void kernel" + exit 1 +fi + +require_command curl +require_command tar +require_command cp +require_command find +require_command grep +require_command cut +require_command readelf +require_command file +require_command install +require_command tail +require_command xz +require_command gzip +require_command bzip2 +require_command dracut + +TMP_DIR="$(mktemp -d -t banger-void-kernel-XXXXXX)" +STATIC_DIR="$TMP_DIR/static" +STAGE_ROOT="$TMP_DIR/root" +STAGE_OUT="$TMP_DIR/out" +STATIC_ARCHIVE="$TMP_DIR/xbps-static.tar.xz" +trap cleanup EXIT + +mkdir -p "$STATIC_DIR" "$STAGE_ROOT/var/db/xbps/keys" "$STAGE_OUT/boot" "$STAGE_OUT/lib/modules" + +log "downloading static XBPS from $STATIC_ARCHIVE_URL" +curl -fsSL "$STATIC_ARCHIVE_URL" -o "$STATIC_ARCHIVE" +tar -xf "$STATIC_ARCHIVE" -C "$STATIC_DIR" + +XBPS_INSTALL="$(find_static_binary xbps-install)" +STATIC_KEYS_DIR="$(find_static_keys_dir)" +if [[ -z "$XBPS_INSTALL" || ! -x "$XBPS_INSTALL" ]]; then + log "failed to locate xbps-install in the static archive" + exit 1 +fi +if [[ -z "$STATIC_KEYS_DIR" || ! -d "$STATIC_KEYS_DIR" ]]; then + log "failed to locate Void repository keys in the static archive" + exit 1 +fi + +cp -a "$STATIC_KEYS_DIR/." "$STAGE_ROOT/var/db/xbps/keys/" + +KERNEL_PACKAGE_FILE="$(resolve_kernel_package_file)" +if [[ -z "$KERNEL_PACKAGE_FILE" ]]; then + log "failed to resolve a package file for $KERNEL_PACKAGE in $REPO_URL" + exit 1 +fi + +log "staging $KERNEL_PACKAGE_FILE into a temporary root" +env XBPS_ARCH="$ARCH" "$XBPS_INSTALL" -S -y -U -r "$STAGE_ROOT" -R "$REPO_URL" linux-base "$KERNEL_PACKAGE" dracut eudev >/dev/null + +VMLINUX_RAW="$(find_latest_matching "$STAGE_ROOT/boot" 'vmlinuz-*' || true)" +KERNEL_CONFIG="$(find_latest_matching "$STAGE_ROOT/boot" 'config-*' || true)" +MODULES_DIR="$(find_latest_module_dir "$STAGE_ROOT/usr/lib/modules" || true)" +KERNEL_VERSION="$(basename "$MODULES_DIR")" +INITRAMFS_NAME="initramfs-${KERNEL_VERSION}.img" +INITRAMFS_RAW="$STAGE_OUT/boot/$INITRAMFS_NAME" + +if [[ -z "$VMLINUX_RAW" || -z "$KERNEL_CONFIG" || -z "$MODULES_DIR" ]]; then + log "staged Void kernel is missing expected boot artifacts" + exit 1 +fi +if [[ ! -x "$STAGE_ROOT/usr/bin/udevd" ]]; then + log "staged Void sysroot is missing /usr/bin/udevd after package install" + exit 1 +fi + +VMLINUX_BASE="$(basename "$VMLINUX_RAW")" +VMLINUX_OUT="$STAGE_OUT/boot/vmlinux-${VMLINUX_BASE#vmlinuz-}" +install -m 0644 "$VMLINUX_RAW" "$STAGE_OUT/boot/$VMLINUX_BASE" +install -m 0644 "$KERNEL_CONFIG" "$STAGE_OUT/boot/$(basename "$KERNEL_CONFIG")" +build_initramfs "$KERNEL_VERSION" "$MODULES_DIR" "$INITRAMFS_RAW" +cp -a "$MODULES_DIR" "$STAGE_OUT/lib/modules/" + +log "extracting Firecracker kernel from $(basename "$VMLINUX_RAW")" +if ! extract_vmlinux "$VMLINUX_RAW" "$VMLINUX_OUT"; then + log "failed to extract an uncompressed vmlinux from $VMLINUX_RAW" + log "raw kernel image type: $(file -b "$VMLINUX_RAW")" + exit 1 +fi + +cat >"$STAGE_OUT/metadata.json" <&2 +} + +find_latest_matching() { + local dir="$1" + local pattern="$2" + if [[ ! -d "$dir" ]]; then + return 1 + fi + find "$dir" -maxdepth 1 -type f -name "$pattern" | sort | tail -n 1 +} + +find_latest_module_dir() { + local root="$1" + if [[ ! -d "$root" ]]; then + return 1 + fi + find "$root" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1 +} + +resolve_banger_bin() { + if [[ -n "${BANGER_BIN:-}" ]]; then + printf '%s\n' "$BANGER_BIN" + return + fi + if [[ -x "$SCRIPT_DIR/banger" ]]; then + printf '%s\n' "$SCRIPT_DIR/banger" + return + fi + if command -v banger >/dev/null 2>&1; then + command -v banger + return + fi + log "banger binary not found; build it first with 'make build' or set BANGER_BIN" + exit 1 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_RUNTIME_DIR="$SCRIPT_DIR" +if [[ -d "$SCRIPT_DIR/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime" +fi + +RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" +IMAGE_NAME="${VOID_IMAGE_NAME:-void-exp}" +BANGER_BIN="$(resolve_banger_bin)" +ROOTFS="$RUNTIME_DIR/rootfs-void.ext4" +WORK_SEED="$RUNTIME_DIR/rootfs-void.work-seed.ext4" +PACKAGES="$SCRIPT_DIR/packages.void" + +if [[ ! -f "$ROOTFS" ]]; then + log "missing Void rootfs: $ROOTFS" + exit 1 +fi +if [[ ! -f "$WORK_SEED" ]]; then + log "missing Void work-seed: $WORK_SEED" + exit 1 +fi + +args=( + image register + --name "$IMAGE_NAME" + --rootfs "$ROOTFS" + --work-seed "$WORK_SEED" + --packages "$PACKAGES" +) + +if [[ ! -d "$RUNTIME_DIR/void-kernel" ]]; then + log "missing staged Void kernel artifacts: $RUNTIME_DIR/void-kernel" + log "run 'make void-kernel' before registering $IMAGE_NAME" + exit 1 +fi + +kernel="$(find_latest_matching "$RUNTIME_DIR/void-kernel/boot" 'vmlinux-*' || true)" +initrd="$(find_latest_matching "$RUNTIME_DIR/void-kernel/boot" 'initramfs-*' || true)" +modules="$(find_latest_module_dir "$RUNTIME_DIR/void-kernel/lib/modules" || true)" + +if [[ -z "$kernel" || -z "$initrd" || -z "$modules" ]]; then + log "staged Void kernel is incomplete; expected vmlinux, initramfs, and modules under $RUNTIME_DIR/void-kernel" + exit 1 +fi + +log "using staged Void kernel artifacts from $RUNTIME_DIR/void-kernel" +args+=(--kernel "$kernel" --initrd "$initrd" --modules "$modules") + +"$BANGER_BIN" "${args[@]}" diff --git a/verify.sh b/verify.sh index 8c0af2d..53478e1 100755 --- a/verify.sh +++ b/verify.sh @@ -33,6 +33,7 @@ SSH_COMMON_ARGS=( -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ) +OPENCODE_PORT=4096 firecracker_running() { local pid="$1" @@ -68,6 +69,21 @@ wait_for_ssh() { return 1 } +wait_for_tcp() { + local host="$1" + local port="$2" + local deadline="$3" + + while ((SECONDS < deadline)); do + if (exec 3<>/dev/tcp/"$host"/"$port") >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + return 1 +} + refresh_vm_metadata() { if ! VM_JSON="$(./banger vm show "$VM_NAME" 2>/dev/null)"; then return 1 @@ -240,9 +256,21 @@ if ! wait_for_ssh "$GUEST_IP" "$BOOT_DEADLINE"; then fi ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "uname -a" >/dev/null -if [[ "$IMAGE_NAME" == "void-exp" ]]; then - log "asserting mise and opencode are available in the Void guest" - ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "command -v mise >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 && mise --version >/dev/null 2>&1 && opencode --version >/dev/null 2>&1" >/dev/null +log "asserting opencode is available and listening in the guest" +ssh "${SSH_COMMON_ARGS[@]}" "root@${GUEST_IP}" "command -v opencode >/dev/null 2>&1 && ss -H -lntp | awk '\$4 ~ /:${OPENCODE_PORT}\$/ { found = 1 } END { exit found ? 0 : 1 }'" >/dev/null + +log "asserting opencode server is reachable from the host" +if ! wait_for_tcp "$GUEST_IP" "$OPENCODE_PORT" "$BOOT_DEADLINE"; then + log "opencode server did not become reachable at ${GUEST_IP}:${OPENCODE_PORT}" + dump_diagnostics + exit 1 +fi + +log "asserting opencode port is reported by banger vm ports" +if ! ./banger vm ports "$VM_NAME" | grep -F ":${OPENCODE_PORT}" >/dev/null 2>&1; then + log "banger vm ports did not report ${OPENCODE_PORT}" + dump_diagnostics + exit 1 fi if (( NAT_ENABLED )); then From 2362d0ae39bab1f3d0779c5f911bef457269d522 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 21 Mar 2026 16:47:47 -0300 Subject: [PATCH 004/257] Serve a local web UI from bangerd Add a localhost-only web console so VM and image management no longer depends on the CLI for every inspection and lifecycle action. Wire bangerd up to a configurable web listener, expose dashboard and async image-build state through the daemon, and serve CSRF-protected HTML pages with host-path picking, VM/image detail views, logs, ports, and progress polling for long-running operations. Keep the browser path aligned with the existing sudo and host-owned artifact model: surface sudo readiness, print the web URL in daemon status, and document the new workflow. Polish the UI with resource usage cards, clearer clickable affordances, cancel paths, confirmation prompts, image-name links, and HTTP port links. Validation: GOCACHE=/tmp/banger-gocache go test ./... --- AGENTS.md | 5 +- README.md | 39 +- internal/api/types.go | 67 ++ internal/cli/banger.go | 12 + internal/cli/cli_test.go | 3 + internal/config/config.go | 55 +- internal/config/config_test.go | 22 + internal/daemon/daemon.go | 81 +- internal/daemon/dashboard.go | 63 ++ internal/daemon/image_build_ops.go | 218 ++++ internal/daemon/images.go | 8 + internal/daemon/web.go | 65 ++ internal/model/types.go | 1 + internal/system/system.go | 16 + internal/webui/assets/app.js | 130 +++ internal/webui/assets/style.css | 513 ++++++++++ internal/webui/server.go | 1246 +++++++++++++++++++++++ internal/webui/server_test.go | 231 +++++ internal/webui/templates/base.html | 124 +++ internal/webui/templates/dashboard.html | 65 ++ internal/webui/templates/error.html | 3 + internal/webui/templates/images.html | 182 ++++ internal/webui/templates/operation.html | 20 + internal/webui/templates/vms.html | 191 ++++ 24 files changed, 3308 insertions(+), 52 deletions(-) create mode 100644 internal/daemon/dashboard.go create mode 100644 internal/daemon/image_build_ops.go create mode 100644 internal/daemon/web.go create mode 100644 internal/webui/assets/app.js create mode 100644 internal/webui/assets/style.css create mode 100644 internal/webui/server.go create mode 100644 internal/webui/server_test.go create mode 100644 internal/webui/templates/base.html create mode 100644 internal/webui/templates/dashboard.html create mode 100644 internal/webui/templates/error.html create mode 100644 internal/webui/templates/images.html create mode 100644 internal/webui/templates/operation.html create mode 100644 internal/webui/templates/vms.html diff --git a/AGENTS.md b/AGENTS.md index c5d6dc7..2f547b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,11 +17,13 @@ - `make verify-void` registers `void-exp` and runs the normal smoke test against that image. - `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set. - `./banger vm create --name testbox` creates and starts a VM. +- `./banger vm create` now blocks until the guest reaches the daemon's default readiness checks and shows live progress stages on TTY stderr while it waits. - `./banger vm ssh testbox` connects to a running guest using the runtime bundle SSH key and reminds the user if the VM is still running when the session exits. - `./banger vm stop testbox` stops a VM while preserving its disks. - `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. - `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. - `./banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void. +- `bangerd` now also serves a localhost web UI on `http://127.0.0.1:7777` by default unless `web_listen_addr = ""` disables it; the UI uses server-rendered templates, polls async VM/image operations, and keeps image path selection on the host via a server-side file picker. - `make test` runs `go test ./...`. - `./verify.sh` runs the smoke test for the Go VM workflow. @@ -35,9 +37,10 @@ - Primary automated coverage is `go test ./...`. - Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM. - For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke. +- The web UI follows the same sudo model as the CLI path: bangerd stays unprivileged and privileged writes only work when `sudo -v` is already warm or sudo is passwordless. - Rebuilt images now include `mise`, `opencode`, a host-reachable default `opencode` server service on guest TCP port `4096`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up. - The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, a guest network bootstrap sourced from the kernel `ip=` cmdline, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, the default host-reachable `opencode` server service on guest TCP port `4096`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. When `./runtime/void-kernel/` exists, the Void image registration path expects a complete staged Void kernel, initramfs, and modules tree and points `void-exp` at it. Keep further baked-in tooling deliberate and user-driven. -- Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. +- Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. Older managed images may take one slower create to refresh seeded SSH access before they rejoin the fast path. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. - The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks. - If you add a new operational workflow, document how to exercise it in `README.md`. - For NAT changes, verify both guest outbound access and host rule cleanup, for example with `./verify.sh --nat`. diff --git a/README.md b/README.md index dac9f0b..18c59d1 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ Create and boot a VM: banger vm create --name calm-otter --disk-size 16G ``` +`banger vm create` now waits for full guest readiness by default, including the +guest vsock agent and the default `opencode` service, and prints live progress +stages on TTY stderr while it waits. + Check host/runtime readiness before creating VMs: ```bash banger doctor @@ -148,7 +152,24 @@ banger daemon stop ``` `banger daemon status` prints the daemon PID, socket path, daemon log path, and -the built-in DNS listener address. +the built-in DNS listener address. The daemon also serves a local web UI on +`http://127.0.0.1:7777` by default, and `daemon status` prints that URL when it +is enabled. + +Use the web UI for dashboard, VM lifecycle, image inventory, VM create +progress, ports/log inspection, and image build/register/promote/delete flows: +```text +http://127.0.0.1:7777 +``` + +The image forms use a server-side host-path picker. They do not upload files +through the browser; they select absolute paths that already exist on the host. +Mutating actions in the UI require the same sudo readiness as the CLI-backed +workflow. If the page shows writes as disabled, run: +```bash +sudo -v +``` +and refresh the page. State lives under XDG directories: - config: `~/.config/banger` @@ -164,6 +185,7 @@ repo-built `./banger`. You can override either with `runtime_dir` in Useful config keys: - `log_level` - `runtime_dir` +- `web_listen_addr` (`""` disables the web UI) - `tap_pool_size` - `firecracker_bin` - `namegen_path` @@ -210,6 +232,10 @@ Build a managed image: banger image build --name docker-dev --docker ``` +The web UI exposes both managed image build and unmanaged image register forms. +Builds run through an async progress page; register, promote, and delete remain +direct form actions. + Rebuilt images install a pinned `mise` at `/usr/local/bin/mise`, activate it for bash login and interactive shells, install `opencode` through `mise`, expose `/usr/local/bin/opencode`, configure `tmux-resurrect` plus @@ -274,8 +300,12 @@ guest IP or via the endpoint shown by `banger vm ports`. - Each VM gets its own sparse writable system overlay for `/`. - Each VM gets its own persistent ext4 work disk mounted at `/root`. - When an image has a `work-seed.ext4` sidecar, new VM creates clone that seed - and only resize it when needed. Older images still work, but create more - slowly because `/root` must be built from scratch. + and only resize it when needed. +- Older managed images without the seeded SSH metadata may take one slower + create to repair `/root` access and refresh their managed work-seed; later + creates use the fast path. +- Images without any `work-seed.ext4` still work, but create more slowly + because `/root` must be built from scratch. - The daemon can keep a small idle TAP pool warm in the background so VM create does not need to synchronously create a fresh TAP every time. `tap_pool_size` controls the pool depth. @@ -462,7 +492,8 @@ make bench-create ARGS="--runs 3 --image docker-dev" ``` The benchmark prints JSON with: -- `create_ms`: wall time for `banger vm create` +- `create_ms`: wall time for `banger vm create`, including full readiness + gating for the guest vsock agent and default `opencode` service - `ssh_ready_ms`: wall time from create start until `banger vm ssh -- true` succeeds diff --git a/internal/api/types.go b/internal/api/types.go index 77ec00e..ca44542 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -11,6 +11,7 @@ type Empty struct{} type PingResult struct { Status string `json:"status"` PID int `json:"pid"` + WebURL string `json:"web_url,omitempty"` } type ShutdownResult struct { @@ -54,6 +55,33 @@ type VMCreateStatusResult struct { Operation VMCreateOperation `json:"operation"` } +type ImageBuildStatusParams struct { + ID string `json:"id"` +} + +type ImageBuildOperation struct { + ID string `json:"id"` + ImageID string `json:"image_id,omitempty"` + ImageName string `json:"image_name,omitempty"` + Stage string `json:"stage,omitempty"` + Detail string `json:"detail,omitempty"` + BuildLogPath string `json:"build_log_path,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Done bool `json:"done"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Image *model.Image `json:"image,omitempty"` +} + +type ImageBuildBeginResult struct { + Operation ImageBuildOperation `json:"operation"` +} + +type ImageBuildStatusResult struct { + Operation ImageBuildOperation `json:"operation"` +} + type VMRefParams struct { IDOrName string `json:"id_or_name"` } @@ -151,3 +179,42 @@ type ImageListResult struct { type ImageShowResult struct { Image model.Image `json:"image"` } + +type SudoStatus struct { + Available bool `json:"available"` + Command string `json:"command,omitempty"` + Error string `json:"error,omitempty"` +} + +type HostSummary struct { + CPUCount int `json:"cpu_count"` + TotalMemoryBytes int64 `json:"total_memory_bytes"` + StateFilesystemTotalBytes int64 `json:"state_filesystem_total_bytes"` + StateFilesystemFreeBytes int64 `json:"state_filesystem_free_bytes"` +} + +type BangerSummary struct { + ImageCount int `json:"image_count"` + ManagedImageCount int `json:"managed_image_count"` + VMCount int `json:"vm_count"` + RunningVMCount int `json:"running_vm_count"` + ConfiguredVCPUCount int `json:"configured_vcpu_count"` + ConfiguredMemoryBytes int64 `json:"configured_memory_bytes"` + ConfiguredDiskBytes int64 `json:"configured_disk_bytes"` + UsedSystemOverlayBytes int64 `json:"used_system_overlay_bytes"` + UsedWorkDiskBytes int64 `json:"used_work_disk_bytes"` + RunningCPUPercent float64 `json:"running_cpu_percent"` + RunningRSSBytes int64 `json:"running_rss_bytes"` + RunningVSZBytes int64 `json:"running_vsz_bytes"` +} + +type DashboardSummary struct { + GeneratedAt time.Time `json:"generated_at"` + Host HostSummary `json:"host"` + Sudo SudoStatus `json:"sudo"` + Banger BangerSummary `json:"banger"` +} + +type DashboardSummaryResult struct { + Summary DashboardSummary `json:"summary"` +} diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 0d53a05..89154e0 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -188,11 +188,23 @@ func newDaemonCommand() *cobra.Command { if err != nil { return err } + cfg, err := config.Load(layout) + if err != nil { + return err + } ping, pingErr := rpc.Call[api.PingResult](cmd.Context(), layout.SocketPath, "ping", api.Empty{}) if pingErr != nil { + if strings.TrimSpace(cfg.WebListenAddr) != "" { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\nweb: http://%s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, cfg.WebListenAddr) + return err + } _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err } + if strings.TrimSpace(ping.WebURL) != "" { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL) + return err + } _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err }, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index e64f6be..49c166a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -798,6 +798,9 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) { if !strings.Contains(output, "dns: 127.0.0.1:42069") { t.Fatalf("output = %q, want dns listener", output) } + if !strings.Contains(output, "web: http://127.0.0.1:7777") { + t.Fatalf("output = %q, want default web listener", output) + } } func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index fc6807d..ebdca41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,36 +15,38 @@ import ( ) type fileConfig struct { - RuntimeDir string `toml:"runtime_dir"` - RepoRoot string `toml:"repo_root"` - LogLevel string `toml:"log_level"` - FirecrackerBin string `toml:"firecracker_bin"` - SSHKeyPath string `toml:"ssh_key_path"` - NamegenPath string `toml:"namegen_path"` - CustomizeScript string `toml:"customize_script"` - VSockAgent string `toml:"vsock_agent_path"` - VSockPingHelper string `toml:"vsock_ping_helper_path"` - DefaultWorkSeed string `toml:"default_work_seed"` - DefaultImageName string `toml:"default_image_name"` - DefaultRootfs string `toml:"default_rootfs"` - 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"` - TapPoolSize int `toml:"tap_pool_size"` - DefaultDNS string `toml:"default_dns"` + RuntimeDir string `toml:"runtime_dir"` + RepoRoot string `toml:"repo_root"` + LogLevel string `toml:"log_level"` + WebListenAddr *string `toml:"web_listen_addr"` + FirecrackerBin string `toml:"firecracker_bin"` + SSHKeyPath string `toml:"ssh_key_path"` + NamegenPath string `toml:"namegen_path"` + CustomizeScript string `toml:"customize_script"` + VSockAgent string `toml:"vsock_agent_path"` + VSockPingHelper string `toml:"vsock_ping_helper_path"` + DefaultWorkSeed string `toml:"default_work_seed"` + DefaultImageName string `toml:"default_image_name"` + DefaultRootfs string `toml:"default_rootfs"` + 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"` + TapPoolSize int `toml:"tap_pool_size"` + DefaultDNS string `toml:"default_dns"` } func Load(layout paths.Layout) (model.DaemonConfig, error) { cfg := model.DaemonConfig{ LogLevel: "info", + WebListenAddr: "127.0.0.1:7777", AutoStopStaleAfter: 0, StatsPollInterval: model.DefaultStatsPollInterval, MetricsPollInterval: model.DefaultMetricsPollInterval, @@ -84,6 +86,9 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { if file.LogLevel != "" { cfg.LogLevel = file.LogLevel } + if file.WebListenAddr != nil { + cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr) + } if file.NamegenPath != "" { cfg.NamegenPath = file.NamegenPath } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4791084..665ab9b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -289,3 +289,25 @@ func TestLoadAcceptsLegacyConfigVsockPingHelperPath(t *testing.T) { t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath) } } + +func TestLoadWebListenAddrDefaultsAndAllowsDisable(t *testing.T) { + cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) + if err != nil { + t.Fatalf("Load default config: %v", err) + } + if cfg.WebListenAddr != "127.0.0.1:7777" { + t.Fatalf("WebListenAddr = %q, want default 127.0.0.1:7777", cfg.WebListenAddr) + } + + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("web_listen_addr = \"\"\n"), 0o644); err != nil { + t.Fatalf("write config.toml: %v", err) + } + cfg, err = Load(paths.Layout{ConfigDir: configDir}) + if err != nil { + t.Fatalf("Load disabled config: %v", err) + } + if cfg.WebListenAddr != "" { + t.Fatalf("WebListenAddr = %q, want disabled empty string", cfg.WebListenAddr) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 6eb0ea6..1042caf 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "net" + "net/http" "os" "path/filepath" "strings" @@ -26,27 +27,32 @@ import ( ) type Daemon struct { - layout paths.Layout - config model.DaemonConfig - store *store.Store - runner system.CommandRunner - logger *slog.Logger - mu sync.Mutex - createOpsMu sync.Mutex - createOps map[string]*vmCreateOperationState - vmLocksMu sync.Mutex - vmLocks map[string]*sync.Mutex - tapPoolMu sync.Mutex - tapPool []string - tapPoolNext int - closing chan struct{} - once sync.Once - pid int - listener net.Listener - vmDNS *vmdns.Server - vmCaps []vmCapability - imageBuild func(context.Context, imageBuildSpec) error - requestHandler func(context.Context, rpc.Request) rpc.Response + layout paths.Layout + config model.DaemonConfig + store *store.Store + runner system.CommandRunner + logger *slog.Logger + mu sync.Mutex + createOpsMu sync.Mutex + createOps map[string]*vmCreateOperationState + imageBuildOpsMu sync.Mutex + imageBuildOps map[string]*imageBuildOperationState + vmLocksMu sync.Mutex + vmLocks map[string]*sync.Mutex + tapPoolMu sync.Mutex + tapPool []string + tapPoolNext int + closing chan struct{} + once sync.Once + pid int + listener net.Listener + webListener net.Listener + webServer *http.Server + webURL string + vmDNS *vmdns.Server + vmCaps []vmCapability + imageBuild func(context.Context, imageBuildSpec) error + requestHandler func(context.Context, rpc.Request) rpc.Response } func Open(ctx context.Context) (d *Daemon, err error) { @@ -115,6 +121,12 @@ func (d *Daemon) Close() error { if d.listener != nil { _ = d.listener.Close() } + if d.webServer != nil { + _ = d.webServer.Close() + } + if d.webListener != nil { + _ = d.webListener.Close() + } err = errors.Join(d.stopVMDNS(), d.store.Close()) }) return err @@ -138,6 +150,9 @@ func (d *Daemon) Serve(ctx context.Context) error { if d.logger != nil { d.logger.Info("daemon serving", "socket", d.layout.SocketPath, "pid", d.pid) } + if err := d.startWebServer(); err != nil { + return err + } go d.backgroundLoop() @@ -238,7 +253,7 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } switch req.Method { case "ping": - result, _ := rpc.NewResult(api.PingResult{Status: "ok", PID: d.pid}) + result, _ := rpc.NewResult(api.PingResult{Status: "ok", PID: d.pid, WebURL: d.webURL}) return result case "shutdown": go d.Close() @@ -392,6 +407,27 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response { } image, err := d.BuildImage(ctx, params) return marshalResultOrError(api.ImageShowResult{Image: image}, err) + case "image.build.begin": + params, err := rpc.DecodeParams[api.ImageBuildParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + op, err := d.BeginImageBuild(ctx, params) + return marshalResultOrError(api.ImageBuildBeginResult{Operation: op}, err) + case "image.build.status": + params, err := rpc.DecodeParams[api.ImageBuildStatusParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + op, err := d.ImageBuildStatus(ctx, params.ID) + return marshalResultOrError(api.ImageBuildStatusResult{Operation: op}, err) + case "image.build.cancel": + params, err := rpc.DecodeParams[api.ImageBuildStatusParams](req) + if err != nil { + return rpc.NewError("bad_request", err.Error()) + } + err = d.CancelImageBuild(ctx, params.ID) + return marshalResultOrError(api.Empty{}, err) case "image.register": params, err := rpc.DecodeParams[api.ImageRegisterParams](req) if err != nil { @@ -436,6 +472,7 @@ func (d *Daemon) backgroundLoop() { d.logger.Error("background stale sweep failed", "error", err.Error()) } d.pruneVMCreateOperations(time.Now().Add(-10 * time.Minute)) + d.pruneImageBuildOperations(time.Now().Add(-10 * time.Minute)) } } } diff --git a/internal/daemon/dashboard.go b/internal/daemon/dashboard.go new file mode 100644 index 0000000..b0953b5 --- /dev/null +++ b/internal/daemon/dashboard.go @@ -0,0 +1,63 @@ +package daemon + +import ( + "context" + + "banger/internal/api" + "banger/internal/model" + "banger/internal/system" +) + +func (d *Daemon) DashboardSummary(ctx context.Context) (api.DashboardSummary, error) { + summary := api.DashboardSummary{ + GeneratedAt: model.Now(), + Sudo: api.SudoStatus{ + Command: "sudo -v", + }, + } + if err := system.CheckSudo(ctx); err != nil { + summary.Sudo.Error = err.Error() + } else { + summary.Sudo.Available = true + } + + if host, err := system.ReadHostResources(); err == nil { + summary.Host.CPUCount = host.CPUCount + summary.Host.TotalMemoryBytes = host.TotalMemoryBytes + } + if usage, err := system.ReadFilesystemUsage(d.layout.StateDir); err == nil { + summary.Host.StateFilesystemTotalBytes = usage.TotalBytes + summary.Host.StateFilesystemFreeBytes = usage.FreeBytes + } + + images, err := d.store.ListImages(ctx) + if err != nil { + return api.DashboardSummary{}, err + } + for _, image := range images { + summary.Banger.ImageCount++ + if image.Managed { + summary.Banger.ManagedImageCount++ + } + } + + vms, err := d.store.ListVMs(ctx) + if err != nil { + return api.DashboardSummary{}, err + } + for _, vm := range vms { + summary.Banger.VMCount++ + summary.Banger.ConfiguredVCPUCount += vm.Spec.VCPUCount + summary.Banger.ConfiguredMemoryBytes += int64(vm.Spec.MemoryMiB) * 1024 * 1024 + summary.Banger.ConfiguredDiskBytes += vm.Spec.WorkDiskSizeBytes + summary.Banger.UsedSystemOverlayBytes += vm.Stats.SystemOverlayBytes + summary.Banger.UsedWorkDiskBytes += vm.Stats.WorkDiskBytes + if vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) { + summary.Banger.RunningVMCount++ + summary.Banger.RunningCPUPercent += vm.Stats.CPUPercent + summary.Banger.RunningRSSBytes += vm.Stats.RSSBytes + summary.Banger.RunningVSZBytes += vm.Stats.VSZBytes + } + } + return summary, nil +} diff --git a/internal/daemon/image_build_ops.go b/internal/daemon/image_build_ops.go new file mode 100644 index 0000000..813a7a2 --- /dev/null +++ b/internal/daemon/image_build_ops.go @@ -0,0 +1,218 @@ +package daemon + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "banger/internal/api" + "banger/internal/model" +) + +type imageBuildProgressKey struct{} + +type imageBuildOperationState struct { + mu sync.Mutex + cancel context.CancelFunc + op api.ImageBuildOperation +} + +func newImageBuildOperationState() (*imageBuildOperationState, error) { + id, err := model.NewID() + if err != nil { + return nil, err + } + now := model.Now() + return &imageBuildOperationState{ + op: api.ImageBuildOperation{ + ID: id, + Stage: "queued", + Detail: "waiting to start", + StartedAt: now, + UpdatedAt: now, + }, + }, nil +} + +func withImageBuildProgress(ctx context.Context, op *imageBuildOperationState) context.Context { + if op == nil { + return ctx + } + return context.WithValue(ctx, imageBuildProgressKey{}, op) +} + +func imageBuildProgressFromContext(ctx context.Context) *imageBuildOperationState { + if ctx == nil { + return nil + } + op, _ := ctx.Value(imageBuildProgressKey{}).(*imageBuildOperationState) + return op +} + +func imageBuildStage(ctx context.Context, stage, detail string) { + if op := imageBuildProgressFromContext(ctx); op != nil { + op.stage(stage, detail) + } +} + +func imageBuildBindImage(ctx context.Context, image model.Image) { + if op := imageBuildProgressFromContext(ctx); op != nil { + op.bindImage(image) + } +} + +func imageBuildSetLogPath(ctx context.Context, path string) { + if op := imageBuildProgressFromContext(ctx); op != nil { + op.setLogPath(path) + } +} + +func (op *imageBuildOperationState) setCancel(cancel context.CancelFunc) { + op.mu.Lock() + defer op.mu.Unlock() + op.cancel = cancel +} + +func (op *imageBuildOperationState) setLogPath(path string) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.BuildLogPath = strings.TrimSpace(path) + op.op.UpdatedAt = model.Now() +} + +func (op *imageBuildOperationState) bindImage(image model.Image) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.ImageID = image.ID + op.op.ImageName = image.Name +} + +func (op *imageBuildOperationState) stage(stage, detail string) { + op.mu.Lock() + defer op.mu.Unlock() + stage = strings.TrimSpace(stage) + detail = strings.TrimSpace(detail) + if stage == "" { + stage = op.op.Stage + } + if stage == op.op.Stage && detail == op.op.Detail { + return + } + op.op.Stage = stage + op.op.Detail = detail + op.op.UpdatedAt = model.Now() +} + +func (op *imageBuildOperationState) done(image model.Image) { + op.mu.Lock() + defer op.mu.Unlock() + imageCopy := image + op.op.ImageID = image.ID + op.op.ImageName = image.Name + op.op.Stage = "ready" + op.op.Detail = "image is ready" + op.op.Done = true + op.op.Success = true + op.op.Error = "" + op.op.Image = &imageCopy + op.op.UpdatedAt = model.Now() +} + +func (op *imageBuildOperationState) fail(err error) { + op.mu.Lock() + defer op.mu.Unlock() + op.op.Done = true + op.op.Success = false + if err != nil { + op.op.Error = err.Error() + } + if strings.TrimSpace(op.op.Detail) == "" { + op.op.Detail = "image build failed" + } + op.op.UpdatedAt = model.Now() +} + +func (op *imageBuildOperationState) snapshot() api.ImageBuildOperation { + op.mu.Lock() + defer op.mu.Unlock() + snapshot := op.op + if snapshot.Image != nil { + imageCopy := *snapshot.Image + snapshot.Image = &imageCopy + } + return snapshot +} + +func (op *imageBuildOperationState) cancelOperation() { + op.mu.Lock() + cancel := op.cancel + op.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (d *Daemon) BeginImageBuild(_ context.Context, params api.ImageBuildParams) (api.ImageBuildOperation, error) { + op, err := newImageBuildOperationState() + if err != nil { + return api.ImageBuildOperation{}, err + } + buildCtx, cancel := context.WithCancel(context.Background()) + op.setCancel(cancel) + + d.imageBuildOpsMu.Lock() + if d.imageBuildOps == nil { + d.imageBuildOps = map[string]*imageBuildOperationState{} + } + d.imageBuildOps[op.op.ID] = op + d.imageBuildOpsMu.Unlock() + + go d.runImageBuildOperation(withImageBuildProgress(buildCtx, op), op, params) + return op.snapshot(), nil +} + +func (d *Daemon) runImageBuildOperation(ctx context.Context, op *imageBuildOperationState, params api.ImageBuildParams) { + image, err := d.BuildImage(ctx, params) + if err != nil { + op.fail(err) + return + } + op.done(image) +} + +func (d *Daemon) ImageBuildStatus(_ context.Context, id string) (api.ImageBuildOperation, error) { + d.imageBuildOpsMu.Lock() + op, ok := d.imageBuildOps[strings.TrimSpace(id)] + d.imageBuildOpsMu.Unlock() + if !ok { + return api.ImageBuildOperation{}, fmt.Errorf("image build operation not found: %s", id) + } + return op.snapshot(), nil +} + +func (d *Daemon) CancelImageBuild(_ context.Context, id string) error { + d.imageBuildOpsMu.Lock() + op, ok := d.imageBuildOps[strings.TrimSpace(id)] + d.imageBuildOpsMu.Unlock() + if !ok { + return fmt.Errorf("image build operation not found: %s", id) + } + op.cancelOperation() + return nil +} + +func (d *Daemon) pruneImageBuildOperations(olderThan time.Time) { + d.imageBuildOpsMu.Lock() + defer d.imageBuildOpsMu.Unlock() + for id, op := range d.imageBuildOps { + snapshot := op.snapshot() + if !snapshot.Done { + continue + } + if snapshot.UpdatedAt.Before(olderThan) { + delete(d.imageBuildOps, id) + } + } +} diff --git a/internal/daemon/images.go b/internal/daemon/images.go index d24aa9c..365e53d 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -30,6 +30,7 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i }() name := params.Name + imageBuildStage(ctx, "resolve_image", "resolving image build inputs") if name == "" { name = fmt.Sprintf("image-%d", model.Now().Unix()) } @@ -57,6 +58,7 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i return model.Image{}, err } buildLogPath = filepath.Join(buildLogDir, id+".log") + imageBuildSetLogPath(ctx, buildLogPath) logFile, err := os.OpenFile(buildLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return model.Image{}, err @@ -93,22 +95,26 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i Size: params.Size, } op.stage("launch_builder", "build_log_path", buildLogPath, "artifact_dir", artifactDir) + imageBuildStage(ctx, "launch_builder", "building rootfs from base image") if err := d.runImageBuild(ctx, spec); err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) return model.Image{}, err } + imageBuildStage(ctx, "prepare_work_seed", "building reusable work seed") if err := system.BuildWorkSeedImage(ctx, d.runner, rootfsPath, workSeedPath); err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) return model.Image{}, err } + imageBuildStage(ctx, "seed_ssh", "seeding runtime SSH access") seededSSHPublicKeyFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) if err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) return model.Image{}, err } + imageBuildStage(ctx, "write_metadata", "writing image metadata") if err := writePackagesMetadata(rootfsPath, d.config.DefaultPackagesFile); err != nil { _ = logFile.Sync() _ = os.RemoveAll(artifactDir) @@ -131,10 +137,12 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i CreatedAt: now, UpdatedAt: now, } + imageBuildBindImage(ctx, image) if err := d.store.UpsertImage(ctx, image); err != nil { return model.Image{}, err } op.stage("persisted", "build_log_path", buildLogPath) + imageBuildStage(ctx, "persisted", "image metadata saved") if d.logger != nil { d.logger.Info("image build log preserved", append(imageLogAttrs(image), "build_log_path", buildLogPath)...) } diff --git a/internal/daemon/web.go b/internal/daemon/web.go new file mode 100644 index 0000000..11cc951 --- /dev/null +++ b/internal/daemon/web.go @@ -0,0 +1,65 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "strings" + "time" + + "banger/internal/model" + "banger/internal/paths" + "banger/internal/webui" +) + +func (d *Daemon) startWebServer() error { + listenAddr := strings.TrimSpace(d.config.WebListenAddr) + if listenAddr == "" { + d.webURL = "" + return nil + } + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + if d.logger != nil { + d.logger.Error("web ui listen failed", "addr", listenAddr, "error", err.Error()) + } + return fmt.Errorf("web ui listen on %s: %w", listenAddr, err) + } + d.webListener = listener + d.webURL = "http://" + listener.Addr().String() + d.webServer = &http.Server{ + Handler: webui.NewHandler(d), + ReadHeaderTimeout: 5 * time.Second, + } + if d.logger != nil { + d.logger.Info("web ui serving", "addr", listener.Addr().String(), "url", d.webURL) + } + go func() { + err := d.webServer.Serve(listener) + if err == nil || errors.Is(err, http.ErrServerClosed) { + return + } + if d.logger != nil { + d.logger.Error("web ui serve failed", "addr", listener.Addr().String(), "error", err.Error()) + } + }() + return nil +} + +func (d *Daemon) Layout() paths.Layout { + return d.layout +} + +func (d *Daemon) Config() model.DaemonConfig { + return d.config +} + +func (d *Daemon) ListVMs(ctx context.Context) ([]model.VMRecord, error) { + return d.store.ListVMs(ctx) +} + +func (d *Daemon) ListImages(ctx context.Context) ([]model.Image, error) { + return d.store.ListImages(ctx) +} diff --git a/internal/model/types.go b/internal/model/types.go index 400019f..2955765 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -37,6 +37,7 @@ const ( type DaemonConfig struct { RuntimeDir string LogLevel string + WebListenAddr string FirecrackerBin string SSHKeyPath string NamegenPath string diff --git a/internal/system/system.go b/internal/system/system.go index f29b464..753b532 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -59,6 +59,22 @@ func EnsureSudo(ctx context.Context) error { return cmd.Run() } +func CheckSudo(ctx context.Context) error { + if _, err := exec.LookPath("sudo"); err != nil { + return err + } + cmd := exec.CommandContext(ctx, "sudo", "-n", "-v") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return err + } + return nil +} + func RequireCommands(ctx context.Context, commands ...string) error { for _, command := range commands { if _, err := exec.LookPath(command); err != nil { diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js new file mode 100644 index 0000000..0897317 --- /dev/null +++ b/internal/webui/assets/app.js @@ -0,0 +1,130 @@ +(() => { + const operationCard = document.querySelector("[data-operation-url]"); + if (operationCard) { + const stageNode = document.getElementById("operation-stage"); + const detailNode = document.getElementById("operation-detail"); + const errorNode = document.getElementById("operation-error"); + const logNode = document.getElementById("operation-log"); + const statusUrl = operationCard.dataset.operationUrl; + const successUrl = operationCard.dataset.operationSuccess; + + const poll = async () => { + const response = await fetch(statusUrl, { headers: { Accept: "application/json" } }); + if (!response.ok) { + return; + } + const payload = await response.json(); + const op = payload.operation || {}; + if (stageNode) stageNode.textContent = op.stage || "queued"; + if (detailNode) detailNode.textContent = op.detail || ""; + if (errorNode) errorNode.textContent = op.error || ""; + if (logNode && op.build_log_path) logNode.textContent = op.build_log_path; + if (op.done && op.success && successUrl) { + window.location.assign(successUrl); + return; + } + if (!op.done) { + window.setTimeout(poll, 1000); + } + }; + window.setTimeout(poll, 800); + } + + const copyButtons = document.querySelectorAll("[data-copy-text]"); + copyButtons.forEach((button) => { + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(button.dataset.copyText || ""); + button.textContent = "Copied"; + window.setTimeout(() => { button.textContent = "Copy"; }, 1000); + } catch (_) {} + }); + }); + + document.querySelectorAll("form[data-confirm]").forEach((form) => { + form.addEventListener("submit", (event) => { + const message = form.dataset.confirm || "Are you sure?"; + if (!window.confirm(message)) { + event.preventDefault(); + } + }); + }); + + const logToggle = document.getElementById("log-auto-refresh"); + if (logToggle) { + const schedule = () => { + if (!logToggle.checked) return; + window.setTimeout(() => { + if (logToggle.checked) { + window.location.reload(); + } + }, 4000); + }; + logToggle.addEventListener("change", schedule); + schedule(); + } + + const dialog = document.getElementById("path-picker"); + if (!dialog) return; + + const listNode = document.getElementById("picker-list"); + const currentPathNode = document.getElementById("picker-current-path"); + const closeButton = document.getElementById("picker-close"); + const selectCurrentButton = document.getElementById("picker-select-current"); + let currentInput = null; + let currentKind = "file"; + let currentPath = "/"; + + const loadListing = async (path) => { + const response = await fetch(`/api/fs?path=${encodeURIComponent(path)}&kind=${encodeURIComponent(currentKind)}`, { + headers: { Accept: "application/json" } + }); + if (!response.ok) return; + const payload = await response.json(); + currentPath = payload.path; + currentPathNode.textContent = payload.path; + listNode.innerHTML = ""; + payload.entries.forEach((entry) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "picker-entry"; + button.dataset.kind = entry.kind; + button.dataset.path = entry.path; + button.innerHTML = `${entry.name}${entry.kind}`; + button.addEventListener("click", () => { + if (entry.kind === "dir" || entry.kind === "up") { + loadListing(entry.path); + return; + } + if (currentInput) { + currentInput.value = entry.path; + dialog.close(); + } + }); + listNode.appendChild(button); + }); + }; + + document.querySelectorAll("[data-picker-target]").forEach((button) => { + button.addEventListener("click", () => { + const fieldName = button.dataset.pickerTarget; + currentKind = button.dataset.pickerKind || "file"; + currentInput = document.querySelector(`input[name="${fieldName}"]`); + if (!currentInput) return; + const initialPath = currentInput.value || "/"; + dialog.showModal(); + loadListing(initialPath); + }); + }); + + document.querySelectorAll("[data-picker-root]").forEach((button) => { + button.addEventListener("click", () => loadListing(button.dataset.pickerRoot || "/")); + }); + + closeButton.addEventListener("click", () => dialog.close()); + selectCurrentButton.addEventListener("click", () => { + if (!currentInput) return; + currentInput.value = currentPath; + dialog.close(); + }); +})(); diff --git a/internal/webui/assets/style.css b/internal/webui/assets/style.css new file mode 100644 index 0000000..0b28255 --- /dev/null +++ b/internal/webui/assets/style.css @@ -0,0 +1,513 @@ +:root { + --bg: #f2eadf; + --panel: rgba(255, 252, 246, 0.92); + --panel-strong: #fffdf7; + --ink: #1f2a22; + --muted: #5f675f; + --accent: #c8622d; + --accent-strong: #9a3f14; + --success: #33643b; + --warning: #9a5b11; + --danger: #8f2f24; + --line: rgba(31, 42, 34, 0.14); + --shadow: 0 24px 60px rgba(57, 41, 24, 0.12); + --radius: 20px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(200, 98, 45, 0.18), transparent 28%), + radial-gradient(circle at top right, rgba(92, 141, 89, 0.14), transparent 24%), + linear-gradient(180deg, #efe1d1 0%, #f7f1ea 48%, #efe8de 100%); +} + +code, pre, input, select, button { + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; +} + +a { color: inherit; text-decoration: none; } +a[href] { cursor: pointer; } +button:not(:disabled) { cursor: pointer; } + +.app-shell { + max-width: 1320px; + margin: 0 auto; + padding: 28px 20px 56px; +} + +.topbar, .content-panel, .summary-card, .banner, .detail-card, .operation-card { + backdrop-filter: blur(12px); + background: var(--panel); + box-shadow: var(--shadow); +} + +.topbar, .content-panel, .banner { + border-radius: var(--radius); +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: end; + gap: 24px; + padding: 24px 28px; +} + +.topbar h1, .panel-head h2, .detail-card h2, .detail-card h3, .operation-card h2, .operation-card h3 { + margin: 0; + font-family: Georgia, "Iowan Old Style", serif; +} + +.eyebrow { + margin: 0 0 8px; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; + color: var(--muted); +} + +.nav { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.nav a, .button { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid transparent; + padding: 11px 16px; + transition: 160ms ease; + cursor: pointer; +} + +.nav a { + background: rgba(255, 255, 255, 0.48); +} + +.nav a.active, .nav a:hover { + background: #fff7ee; + border-color: rgba(200, 98, 45, 0.22); +} + +.banner { + margin-top: 18px; + padding: 16px 20px; + display: flex; + gap: 12px; + flex-wrap: wrap; + border: 1px solid var(--line); +} + +.banner.warning { border-color: rgba(154, 91, 17, 0.25); } +.banner.success { border-color: rgba(51, 100, 59, 0.25); } +.banner.error { border-color: rgba(143, 47, 36, 0.25); } +.banner.info { border-color: rgba(31, 42, 34, 0.18); } + +.summary-grid, .detail-grid, .split-grid, .command-grid { + display: grid; + gap: 16px; + margin-top: 20px; +} + +.summary-grid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.summary-card, .detail-card, .operation-card { + border-radius: 18px; + border: 1px solid var(--line); + padding: 18px 20px; +} + +.detail-card h2, .operation-card h2 { + margin-bottom: 12px; + font-size: 1.25rem; +} + +.summary-card p:last-child { margin: 0; color: var(--muted); } + +.resource-card { + display: grid; + gap: 14px; + padding: 20px 22px; + overflow: hidden; + position: relative; +} + +.resource-card::before { + content: ""; + position: absolute; + inset: 0; + opacity: 0.7; + pointer-events: none; +} + +.resource-card.cpu::before { + background: radial-gradient(circle at top right, rgba(200, 98, 45, 0.18), transparent 38%); +} + +.resource-card.memory::before { + background: radial-gradient(circle at top right, rgba(92, 141, 89, 0.16), transparent 38%); +} + +.resource-card.disk::before { + background: radial-gradient(circle at top right, rgba(31, 42, 34, 0.1), transparent 42%); +} + +.resource-head, .resource-foot { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; + position: relative; + z-index: 1; +} + +.resource-card h2 { + margin: 0; + font-size: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.resource-ratio { + font-size: 1.8rem; + line-height: 1; + letter-spacing: -0.04em; +} + +.resource-meter { + position: relative; + z-index: 1; + height: 16px; + border-radius: 999px; + overflow: hidden; + border: 1px solid rgba(31, 42, 34, 0.12); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(236, 227, 216, 0.9)), + repeating-linear-gradient(90deg, rgba(31, 42, 34, 0.05) 0 32px, transparent 32px 64px); +} + +.resource-fill { + display: block; + height: 100%; + border-radius: inherit; + position: relative; +} + +.resource-fill::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.28) 0 10px, transparent 10px 20px); +} + +.resource-card.cpu .resource-fill { + background: linear-gradient(90deg, #c8622d, #e08a4f); +} + +.resource-card.memory .resource-fill { + background: linear-gradient(90deg, #4d8155, #79ab72); +} + +.resource-card.disk .resource-fill { + background: linear-gradient(90deg, #415147, #69806f); +} + +.resource-foot { + font-size: 0.86rem; + color: var(--muted); +} + +.summary-notes { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} + +.summary-notes span { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 252, 246, 0.72); + color: var(--muted); +} + +.content-panel { + margin-top: 22px; + padding: 28px; +} + +.panel-head, .section-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.section-head { margin-bottom: 16px; } + +.muted { color: var(--muted); } +.inline-error { + background: rgba(143, 47, 36, 0.08); + color: var(--danger); + border: 1px solid rgba(143, 47, 36, 0.2); + padding: 14px 16px; + border-radius: 14px; + margin-bottom: 18px; +} + +table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--line); + border-radius: 16px; + overflow: hidden; +} + +th, td { + text-align: left; + padding: 14px 12px; + border-bottom: 1px solid var(--line); + vertical-align: top; +} + +th { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); + background: rgba(255,255,255,0.42); +} + +tr:last-child td { border-bottom: 0; } + +.table-link { + font-weight: 600; + transition: 160ms ease; + cursor: pointer; +} + +.table-link:hover { + font-weight: 700; + text-decoration: underline; +} + +.state-pill { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + padding: 6px 10px; + font-size: 0.82rem; + border: 1px solid var(--line); +} + +.state-pill.running { color: var(--success); border-color: rgba(51, 100, 59, 0.25); } +.state-pill.stopped { color: var(--muted); } +.state-pill.error { color: var(--danger); border-color: rgba(143, 47, 36, 0.22); } + +.button { + background: var(--accent); + color: #fff8f0; + border: 1px solid rgba(0,0,0,0.04); + font-weight: 600; +} + +.button:hover { + background: var(--accent-strong); + font-weight: 700; + text-decoration: underline; +} +.button.secondary { + background: rgba(255,255,255,0.74); + color: var(--ink); + border-color: rgba(31, 42, 34, 0.12); +} +.button.danger { background: var(--danger); } +.button:disabled { opacity: 0.55; cursor: not-allowed; } + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 16px; +} + +.form-grid.compact { margin-top: 12px; } + +label { + display: grid; + gap: 8px; + font-size: 0.94rem; +} + +input[type="text"], input[type="number"], select { + width: 100%; + border: 1px solid rgba(31, 42, 34, 0.18); + border-radius: 14px; + padding: 12px 14px; + background: var(--panel-strong); + color: var(--ink); +} + +.checkbox { + grid-auto-flow: column; + justify-content: start; + align-items: center; +} + +.checkbox.inline { display: inline-flex; gap: 8px; } + +.stack-inline { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.form-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.detail-grid { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.split-grid { + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); +} + +.command-grid { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + margin: 18px 0; +} + +dl { + margin: 14px 0 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 10px 12px; +} + +dt { color: var(--muted); } +dd { margin: 0; word-break: break-word; } + +pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.log-output { + min-height: 260px; + padding: 16px; + border-radius: 16px; + background: #201d1a; + color: #f3eee4; + overflow: auto; +} + +.picker-field { grid-column: 1 / -1; } +.picker-input { display: flex; gap: 10px; } +.picker-input input { flex: 1; } + +.picker-dialog { + border: 0; + padding: 0; + border-radius: 22px; + width: min(960px, calc(100vw - 24px)); + max-width: 100%; +} + +.picker-dialog::backdrop { + background: rgba(17, 12, 8, 0.48); +} + +.picker-shell { + display: grid; + grid-template-columns: 220px 1fr; + min-height: 420px; +} + +.picker-sidebar { + padding: 20px; + border-right: 1px solid var(--line); + background: rgba(255,255,255,0.56); +} + +.picker-roots { + display: grid; + gap: 8px; +} + +.picker-root, .picker-entry { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--line); + background: white; + border-radius: 12px; + padding: 10px 12px; + cursor: pointer; +} + +.picker-main { + padding: 20px; +} + +.picker-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.picker-actions { + display: flex; + gap: 10px; +} + +.picker-list { + display: grid; + gap: 8px; + max-height: 320px; + overflow: auto; + margin-top: 16px; +} + +.picker-help { color: var(--muted); margin: 12px 0 0; } + +.operation-card { + min-height: 180px; + display: grid; + gap: 12px; + align-content: start; +} + +@media (max-width: 760px) { + .app-shell { padding: 18px 14px 40px; } + .topbar, .content-panel { padding: 20px; } + .resource-ratio { font-size: 1.45rem; } + .picker-shell { grid-template-columns: 1fr; } + .picker-sidebar { border-right: 0; border-bottom: 1px solid var(--line); } + .picker-input { flex-direction: column; } +} diff --git a/internal/webui/server.go b/internal/webui/server.go new file mode 100644 index 0000000..d87dccb --- /dev/null +++ b/internal/webui/server.go @@ -0,0 +1,1246 @@ +package webui + +import ( + "context" + "crypto/rand" + "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html/template" + "io/fs" + "math" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "banger/internal/api" + "banger/internal/model" + "banger/internal/paths" +) + +type Backend interface { + Config() model.DaemonConfig + Layout() paths.Layout + DashboardSummary(context.Context) (api.DashboardSummary, error) + ListVMs(context.Context) ([]model.VMRecord, error) + FindVM(context.Context, string) (model.VMRecord, error) + GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error) + BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error) + VMCreateStatus(context.Context, string) (api.VMCreateOperation, error) + StartVM(context.Context, string) (model.VMRecord, error) + StopVM(context.Context, string) (model.VMRecord, error) + RestartVM(context.Context, string) (model.VMRecord, error) + DeleteVM(context.Context, string) (model.VMRecord, error) + SetVM(context.Context, api.VMSetParams) (model.VMRecord, error) + PortsVM(context.Context, string) (api.VMPortsResult, error) + ListImages(context.Context) ([]model.Image, error) + FindImage(context.Context, string) (model.Image, error) + BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error) + ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error) + RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) + PromoteImage(context.Context, string) (model.Image, error) + DeleteImage(context.Context, string) (model.Image, error) +} + +type Server struct { + backend Backend + templates *template.Template + pickerFS fs.FS +} + +type pickerRoot struct { + Label string + Path string +} + +type flashMessage struct { + Kind string + Message string +} + +type vmCreateForm struct { + Name string + ImageName string + VCPU string + Memory string + SystemOverlaySize string + WorkDiskSize string + NATEnabled bool + NoStart bool +} + +type vmSetForm struct { + VCPU string + Memory string + WorkDiskSize string + NATEnabled bool +} + +type imageBuildForm struct { + Name string + BaseRootfs string + Size string + KernelPath string + InitrdPath string + ModulesDir string + Docker bool +} + +type imageRegisterForm struct { + Name string + RootfsPath string + WorkSeedPath string + KernelPath string + InitrdPath string + ModulesDir string + PackagesPath string + Docker bool +} + +type pageData struct { + Title string + BodyTemplate string + BodyHTML template.HTML + Section string + Summary api.DashboardSummary + Flash *flashMessage + CSRFToken string + PickerRoots []pickerRoot + MutationAllowed bool + ErrorMessage string + VMs []model.VMRecord + VM model.VMRecord + VMImage model.Image + VMStats model.VMStats + VMPorts api.VMPortsResult + VMPortsError string + VMCreateForm vmCreateForm + VMSetForm vmSetForm + Images []model.Image + Image model.Image + ImageUsers int + ImageBuildForm imageBuildForm + ImageRegisterForm imageRegisterForm + LogText string + VMCreateOperation *api.VMCreateOperation + ImageBuildOperation *api.ImageBuildOperation + OperationStatusURL string + OperationSuccessURL string + OperationLogPath string + OperationKind string +} + +type fsEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Kind string `json:"kind"` +} + +type fsListingResponse struct { + Path string `json:"path"` + Parent string `json:"parent,omitempty"` + Kind string `json:"kind"` + Entries []fsEntry `json:"entries"` + Roots []pickerRoot `json:"roots"` +} + +//go:embed templates/*.html assets/* +var embeddedAssets embed.FS + +func NewHandler(backend Backend) http.Handler { + tmpl := template.Must(template.New("page").Funcs(template.FuncMap{ + "shortID": shortID, + "formatBytes": formatBytes, + "formatBytesCompact": formatBytesCompact, + "formatPercent": formatPercent, + "percentOf": percentOf, + "relativeTime": relativeTime, + "formatBool": formatBool, + "stateClass": stateClass, + "findImage": findImage, + "endpointHref": endpointHref, + "sumInt64": sumInt64, + "eq": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) }, + }).ParseFS(embeddedAssets, "templates/*.html")) + staticFS, err := fs.Sub(embeddedAssets, "assets") + if err != nil { + panic(err) + } + server := &Server{ + backend: backend, + templates: tmpl, + pickerFS: staticFS, + } + mux := http.NewServeMux() + server.registerRoutes(mux) + return mux +} + +func (s *Server) registerRoutes(mux *http.ServeMux) { + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(s.pickerFS))) + mux.HandleFunc("GET /", s.wrap(s.handleDashboard)) + mux.HandleFunc("GET /vms", s.wrap(s.handleVMList)) + mux.HandleFunc("GET /vms/new", s.wrap(s.handleVMNew)) + mux.HandleFunc("POST /vms", s.wrap(s.handleVMCreate)) + mux.HandleFunc("GET /vms/{id}", s.wrap(s.handleVMShow)) + mux.HandleFunc("GET /vms/{id}/logs", s.wrap(s.handleVMLogs)) + mux.HandleFunc("POST /vms/{id}/start", s.wrap(s.handleVMStart)) + mux.HandleFunc("POST /vms/{id}/stop", s.wrap(s.handleVMStop)) + mux.HandleFunc("POST /vms/{id}/restart", s.wrap(s.handleVMRestart)) + mux.HandleFunc("POST /vms/{id}/delete", s.wrap(s.handleVMDelete)) + mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet)) + mux.HandleFunc("GET /images", s.wrap(s.handleImageList)) + mux.HandleFunc("GET /images/build", s.wrap(s.handleImageBuildForm)) + mux.HandleFunc("POST /images/build", s.wrap(s.handleImageBuild)) + mux.HandleFunc("GET /images/register", s.wrap(s.handleImageRegisterForm)) + mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister)) + mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow)) + mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote)) + mux.HandleFunc("POST /images/{id}/delete", s.wrap(s.handleImageDelete)) + mux.HandleFunc("GET /operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationPage)) + mux.HandleFunc("GET /operations/image-build/{id}", s.wrap(s.handleImageBuildOperationPage)) + mux.HandleFunc("GET /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI)) + mux.HandleFunc("GET /api/operations/image-build/{id}", s.wrap(s.handleImageBuildOperationAPI)) + mux.HandleFunc("GET /api/fs", s.wrap(s.handleFSAPI)) +} + +func (s *Server) wrap(fn func(http.ResponseWriter, *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := fn(w, r); err != nil { + s.writeError(w, r, err) + } + } +} + +func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) { + status := http.StatusInternalServerError + lower := strings.ToLower(err.Error()) + switch { + case errors.Is(err, os.ErrNotExist), strings.Contains(lower, "not found"): + status = http.StatusNotFound + case strings.Contains(lower, "csrf"), strings.Contains(lower, "cross-origin"): + status = http.StatusForbidden + case strings.Contains(lower, "path must"), strings.Contains(lower, "not a directory"): + status = http.StatusBadRequest + } + if status == http.StatusInternalServerError { + http.Error(w, err.Error(), status) + return + } + if renderErr := s.renderPage(w, r, status, "Not Found", "error_content", func(data *pageData) error { + data.Section = "none" + data.ErrorMessage = err.Error() + return nil + }); renderErr != nil { + http.Error(w, err.Error(), status) + } +} + +func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int, title, body string, fill func(*pageData) error) error { + summary, err := s.backend.DashboardSummary(r.Context()) + if err != nil { + return err + } + flash := s.popFlash(w, r) + data := &pageData{ + Title: title, + BodyTemplate: body, + Summary: summary, + Flash: flash, + CSRFToken: s.ensureCSRFToken(w, r), + PickerRoots: s.pickerRoots(), + MutationAllowed: summary.Sudo.Available, + } + if fill != nil { + if err := fill(data); err != nil { + return err + } + } + var bodyHTML strings.Builder + if err := s.templates.ExecuteTemplate(&bodyHTML, body, data); err != nil { + return err + } + data.BodyHTML = template.HTML(bodyHTML.String()) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + return s.templates.ExecuteTemplate(w, "page", data) +} + +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) error { + return s.renderPage(w, r, http.StatusOK, "Dashboard", "dashboard_content", func(data *pageData) error { + data.Section = "dashboard" + vms, err := s.backend.ListVMs(r.Context()) + if err != nil { + return err + } + images, err := s.backend.ListImages(r.Context()) + if err != nil { + return err + } + data.VMs = vms + data.Images = images + return nil + }) +} + +func (s *Server) handleVMList(w http.ResponseWriter, r *http.Request) error { + return s.renderPage(w, r, http.StatusOK, "VMs", "vm_list_content", func(data *pageData) error { + data.Section = "vms" + vms, err := s.backend.ListVMs(r.Context()) + if err != nil { + return err + } + images, err := s.backend.ListImages(r.Context()) + if err != nil { + return err + } + data.VMs = vms + data.Images = images + return nil + }) +} + +func (s *Server) handleVMNew(w http.ResponseWriter, r *http.Request) error { + return s.renderVMNewPage(w, r, vmCreateForm{ + VCPU: strconv.Itoa(model.DefaultVCPUCount), + Memory: strconv.Itoa(model.DefaultMemoryMiB), + SystemOverlaySize: model.FormatSizeBytes(model.DefaultSystemOverlaySize), + WorkDiskSize: model.FormatSizeBytes(model.DefaultWorkDiskSize), + }, "") +} + +func (s *Server) renderVMNewPage(w http.ResponseWriter, r *http.Request, form vmCreateForm, formErr string) error { + return s.renderPage(w, r, http.StatusOK, "Create VM", "vm_new_content", func(data *pageData) error { + data.Section = "vms" + images, err := s.backend.ListImages(r.Context()) + if err != nil { + return err + } + data.Images = images + data.VMCreateForm = form + data.ErrorMessage = formErr + return nil + }) +} + +func (s *Server) handleVMCreate(w http.ResponseWriter, r *http.Request) error { + if err := s.verifyPOST(w, r); err != nil { + return err + } + allowed, err := s.requireMutationAllowed(r.Context()) + if err != nil { + return err + } + form, params, err := s.parseVMCreateForm(r) + if err != nil { + return s.renderVMNewPage(w, r, form, err.Error()) + } + if !allowed { + return s.renderVMNewPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds") + } + op, err := s.backend.BeginVMCreate(r.Context(), params) + if err != nil { + return s.renderVMNewPage(w, r, form, err.Error()) + } + http.Redirect(w, r, "/operations/vm-create/"+url.PathEscape(op.ID), http.StatusSeeOther) + return nil +} + +func (s *Server) handleVMShow(w http.ResponseWriter, r *http.Request) error { + _, vmStats, err := s.backend.GetVMStats(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + vm, err := s.backend.FindVM(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + image, _ := s.backend.FindImage(r.Context(), vm.ImageID) + return s.renderPage(w, r, http.StatusOK, vm.Name, "vm_show_content", func(data *pageData) error { + data.Section = "vms" + data.VM = vm + data.VMImage = image + data.VMStats = vmStats + data.VMSetForm = vmSetForm{ + VCPU: strconv.Itoa(vm.Spec.VCPUCount), + Memory: strconv.Itoa(vm.Spec.MemoryMiB), + WorkDiskSize: model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), + NATEnabled: vm.Spec.NATEnabled, + } + if vm.State == model.VMStateRunning { + ports, err := s.backend.PortsVM(r.Context(), vm.ID) + if err != nil { + data.VMPortsError = err.Error() + } else { + data.VMPorts = ports + } + } + return nil + }) +} + +func (s *Server) handleVMLogs(w http.ResponseWriter, r *http.Request) error { + vm, err := s.backend.FindVM(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + logText, err := tailFile(vm.Runtime.LogPath, 200) + if err != nil { + logText = err.Error() + } + return s.renderPage(w, r, http.StatusOK, vm.Name+" Logs", "vm_logs_content", func(data *pageData) error { + data.Section = "vms" + data.VM = vm + data.LogText = logText + return nil + }) +} + +func (s *Server) handleVMStart(w http.ResponseWriter, r *http.Request) error { + return s.runVMAction(w, r, func(ctx context.Context, id string) error { + _, err := s.backend.StartVM(ctx, id) + return err + }, "VM started") +} + +func (s *Server) handleVMStop(w http.ResponseWriter, r *http.Request) error { + return s.runVMAction(w, r, func(ctx context.Context, id string) error { + _, err := s.backend.StopVM(ctx, id) + return err + }, "VM stopped") +} + +func (s *Server) handleVMRestart(w http.ResponseWriter, r *http.Request) error { + return s.runVMAction(w, r, func(ctx context.Context, id string) error { + _, err := s.backend.RestartVM(ctx, id) + return err + }, "VM restarted") +} + +func (s *Server) handleVMDelete(w http.ResponseWriter, r *http.Request) error { + if err := s.verifyPOST(w, r); err != nil { + return err + } + allowed, err := s.requireMutationAllowed(r.Context()) + if err != nil { + return err + } + if !allowed { + s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") + http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther) + return nil + } + if _, err := s.backend.DeleteVM(r.Context(), r.PathValue("id")); err != nil { + s.setFlash(w, "error", err.Error()) + http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther) + return nil + } + s.setFlash(w, "success", "VM deleted") + http.Redirect(w, r, "/vms", http.StatusSeeOther) + return nil +} + +func (s *Server) handleVMSet(w http.ResponseWriter, r *http.Request) error { + if err := s.verifyPOST(w, r); err != nil { + return err + } + allowed, err := s.requireMutationAllowed(r.Context()) + if err != nil { + return err + } + target := "/vms/" + url.PathEscape(r.PathValue("id")) + if !allowed { + s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + vm, err := s.backend.FindVM(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + params, err := s.parseVMSetForm(r, vm) + if err != nil { + s.setFlash(w, "error", err.Error()) + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil { + s.setFlash(w, "info", "No VM settings changed") + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + if _, err := s.backend.SetVM(r.Context(), params); err != nil { + s.setFlash(w, "error", err.Error()) + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + s.setFlash(w, "success", "VM settings updated") + http.Redirect(w, r, target, http.StatusSeeOther) + return nil +} + +func (s *Server) runVMAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) error, successMessage string) error { + if err := s.verifyPOST(w, r); err != nil { + return err + } + allowed, err := s.requireMutationAllowed(r.Context()) + if err != nil { + return err + } + target := "/vms/" + url.PathEscape(r.PathValue("id")) + if !allowed { + s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + if err := action(r.Context(), r.PathValue("id")); err != nil { + s.setFlash(w, "error", err.Error()) + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + s.setFlash(w, "success", successMessage) + http.Redirect(w, r, target, http.StatusSeeOther) + return nil +} + +func (s *Server) handleImageList(w http.ResponseWriter, r *http.Request) error { + return s.renderPage(w, r, http.StatusOK, "Images", "image_list_content", func(data *pageData) error { + data.Section = "images" + images, err := s.backend.ListImages(r.Context()) + if err != nil { + return err + } + data.Images = images + return nil + }) +} + +func (s *Server) handleImageBuildForm(w http.ResponseWriter, r *http.Request) error { + cfg := s.backend.Config() + return s.renderImageBuildPage(w, r, imageBuildForm{ + BaseRootfs: cfg.DefaultBaseRootfs, + KernelPath: cfg.DefaultKernel, + InitrdPath: cfg.DefaultInitrd, + ModulesDir: cfg.DefaultModulesDir, + }, "") +} + +func (s *Server) renderImageBuildPage(w http.ResponseWriter, r *http.Request, form imageBuildForm, formErr string) error { + return s.renderPage(w, r, http.StatusOK, "Build Image", "image_build_content", func(data *pageData) error { + data.Section = "images" + data.ImageBuildForm = form + data.ErrorMessage = formErr + return nil + }) +} + +func (s *Server) handleImageBuild(w http.ResponseWriter, r *http.Request) error { + if err := s.verifyPOST(w, r); err != nil { + return err + } + allowed, err := s.requireMutationAllowed(r.Context()) + if err != nil { + return err + } + form, params, err := s.parseImageBuildForm(r) + if err != nil { + return s.renderImageBuildPage(w, r, form, err.Error()) + } + if !allowed { + return s.renderImageBuildPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds") + } + op, err := s.backend.BeginImageBuild(r.Context(), params) + if err != nil { + return s.renderImageBuildPage(w, r, form, err.Error()) + } + http.Redirect(w, r, "/operations/image-build/"+url.PathEscape(op.ID), http.StatusSeeOther) + return nil +} + +func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error { + cfg := s.backend.Config() + return s.renderImageRegisterPage(w, r, imageRegisterForm{ + KernelPath: cfg.DefaultKernel, + InitrdPath: cfg.DefaultInitrd, + ModulesDir: cfg.DefaultModulesDir, + }, "") +} + +func (s *Server) renderImageRegisterPage(w http.ResponseWriter, r *http.Request, form imageRegisterForm, formErr string) error { + return s.renderPage(w, r, http.StatusOK, "Register Image", "image_register_content", func(data *pageData) error { + data.Section = "images" + data.ImageRegisterForm = form + data.ErrorMessage = formErr + return nil + }) +} + +func (s *Server) handleImageRegister(w http.ResponseWriter, r *http.Request) error { + if err := s.verifyPOST(w, r); err != nil { + return err + } + allowed, err := s.requireMutationAllowed(r.Context()) + if err != nil { + return err + } + form, params, err := s.parseImageRegisterForm(r) + if err != nil { + return s.renderImageRegisterPage(w, r, form, err.Error()) + } + if !allowed { + return s.renderImageRegisterPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds") + } + image, err := s.backend.RegisterImage(r.Context(), params) + if err != nil { + return s.renderImageRegisterPage(w, r, form, err.Error()) + } + s.setFlash(w, "success", "Image registered") + http.Redirect(w, r, "/images/"+url.PathEscape(image.ID), http.StatusSeeOther) + return nil +} + +func (s *Server) handleImageShow(w http.ResponseWriter, r *http.Request) error { + image, err := s.backend.FindImage(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + vms, err := s.backend.ListVMs(r.Context()) + if err != nil { + return err + } + userCount := 0 + for _, vm := range vms { + if vm.ImageID == image.ID { + userCount++ + } + } + return s.renderPage(w, r, http.StatusOK, image.Name, "image_show_content", func(data *pageData) error { + data.Section = "images" + data.Image = image + data.ImageUsers = userCount + return nil + }) +} + +func (s *Server) handleImagePromote(w http.ResponseWriter, r *http.Request) error { + if err := s.verifyPOST(w, r); err != nil { + return err + } + allowed, err := s.requireMutationAllowed(r.Context()) + if err != nil { + return err + } + target := "/images/" + url.PathEscape(r.PathValue("id")) + if !allowed { + s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + if _, err := s.backend.PromoteImage(r.Context(), r.PathValue("id")); err != nil { + s.setFlash(w, "error", err.Error()) + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + s.setFlash(w, "success", "Image promoted") + http.Redirect(w, r, target, http.StatusSeeOther) + return nil +} + +func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) error { + if err := s.verifyPOST(w, r); err != nil { + return err + } + allowed, err := s.requireMutationAllowed(r.Context()) + if err != nil { + return err + } + target := "/images/" + url.PathEscape(r.PathValue("id")) + if !allowed { + s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds") + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + if _, err := s.backend.DeleteImage(r.Context(), r.PathValue("id")); err != nil { + s.setFlash(w, "error", err.Error()) + http.Redirect(w, r, target, http.StatusSeeOther) + return nil + } + s.setFlash(w, "success", "Image deleted") + http.Redirect(w, r, "/images", http.StatusSeeOther) + return nil +} + +func (s *Server) handleVMCreateOperationPage(w http.ResponseWriter, r *http.Request) error { + op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + return s.renderPage(w, r, http.StatusOK, "Creating VM", "operation_content", func(data *pageData) error { + data.Section = "vms" + data.OperationKind = "vm" + data.VMCreateOperation = &op + data.OperationStatusURL = "/api/operations/vm-create/" + url.PathEscape(op.ID) + if op.VMID != "" { + data.OperationSuccessURL = "/vms/" + url.PathEscape(op.VMID) + } + return nil + }) +} + +func (s *Server) handleImageBuildOperationPage(w http.ResponseWriter, r *http.Request) error { + op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + return s.renderPage(w, r, http.StatusOK, "Building Image", "operation_content", func(data *pageData) error { + data.Section = "images" + data.OperationKind = "image" + data.ImageBuildOperation = &op + data.OperationStatusURL = "/api/operations/image-build/" + url.PathEscape(op.ID) + if op.ImageID != "" { + data.OperationSuccessURL = "/images/" + url.PathEscape(op.ImageID) + } + data.OperationLogPath = op.BuildLogPath + return nil + }) +} + +func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Request) error { + op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + return writeJSON(w, api.VMCreateStatusResult{Operation: op}) +} + +func (s *Server) handleImageBuildOperationAPI(w http.ResponseWriter, r *http.Request) error { + op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id")) + if err != nil { + return err + } + return writeJSON(w, api.ImageBuildStatusResult{Operation: op}) +} + +func (s *Server) handleFSAPI(w http.ResponseWriter, r *http.Request) error { + path := strings.TrimSpace(r.URL.Query().Get("path")) + if path == "" { + path = s.pickerRoots()[0].Path + } + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + return fmt.Errorf("path must be absolute") + } + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + kind := r.URL.Query().Get("kind") + if kind != "dir" { + kind = "file" + } + entries, err := os.ReadDir(path) + if err != nil { + return err + } + result := fsListingResponse{ + Path: path, + Kind: kind, + Entries: make([]fsEntry, 0, len(entries)+1), + Roots: s.pickerRoots(), + } + parent := filepath.Dir(path) + if parent != path { + result.Parent = parent + result.Entries = append(result.Entries, fsEntry{Name: "..", Path: parent, Kind: "up"}) + } + for _, entry := range entries { + entryKind := "file" + if entry.IsDir() { + entryKind = "dir" + } + result.Entries = append(result.Entries, fsEntry{ + Name: entry.Name(), + Path: filepath.Join(path, entry.Name()), + Kind: entryKind, + }) + } + sort.Slice(result.Entries, func(i, j int) bool { + left, right := result.Entries[i], result.Entries[j] + leftRank := kindRank(left.Kind) + rightRank := kindRank(right.Kind) + if leftRank != rightRank { + return leftRank < rightRank + } + return strings.ToLower(left.Name) < strings.ToLower(right.Name) + }) + return writeJSON(w, result) +} + +func kindRank(kind string) int { + switch kind { + case "up": + return 0 + case "dir": + return 1 + default: + return 2 + } +} + +func (s *Server) pickerRoots() []pickerRoot { + seen := map[string]struct{}{} + roots := []pickerRoot{{Label: "Filesystem", Path: "/"}} + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + roots = append(roots, pickerRoot{Label: "Home", Path: home}) + } + layout := s.backend.Layout() + if layout.StateDir != "" { + roots = append(roots, pickerRoot{Label: "State", Path: layout.StateDir}) + } + if runtimeDir := s.backend.Config().RuntimeDir; runtimeDir != "" { + roots = append(roots, pickerRoot{Label: "Runtime", Path: runtimeDir}) + } + result := make([]pickerRoot, 0, len(roots)) + for _, root := range roots { + root.Path = filepath.Clean(root.Path) + if _, ok := seen[root.Path]; ok { + continue + } + seen[root.Path] = struct{}{} + result = append(result, root) + } + return result +} + +func (s *Server) verifyPOST(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodPost { + return nil + } + if err := r.ParseForm(); err != nil { + return err + } + if err := verifySameOrigin(r); err != nil { + return err + } + tokenCookie, err := r.Cookie("banger_csrf") + if err != nil { + return errors.New("missing csrf cookie") + } + if tokenCookie.Value == "" || r.FormValue("csrf_token") != tokenCookie.Value { + return errors.New("csrf token mismatch") + } + return nil +} + +func verifySameOrigin(r *http.Request) error { + for _, raw := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} { + if strings.TrimSpace(raw) == "" { + continue + } + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid origin: %w", err) + } + if parsed.Host != r.Host { + return errors.New("cross-origin POST rejected") + } + return nil + } + return nil +} + +func (s *Server) ensureCSRFToken(w http.ResponseWriter, r *http.Request) string { + if cookie, err := r.Cookie("banger_csrf"); err == nil && strings.TrimSpace(cookie.Value) != "" { + return cookie.Value + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + token := hex.EncodeToString(buf) + http.SetCookie(w, &http.Cookie{ + Name: "banger_csrf", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + return token +} + +func (s *Server) setFlash(w http.ResponseWriter, kind, message string) { + payload := base64.RawURLEncoding.EncodeToString([]byte(kind + "\n" + message)) + http.SetCookie(w, &http.Cookie{ + Name: "banger_flash", + Value: payload, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) *flashMessage { + cookie, err := r.Cookie("banger_flash") + if err != nil || cookie.Value == "" { + return nil + } + http.SetCookie(w, &http.Cookie{ + Name: "banger_flash", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + data, err := base64.RawURLEncoding.DecodeString(cookie.Value) + if err != nil { + return nil + } + parts := strings.SplitN(string(data), "\n", 2) + if len(parts) != 2 { + return nil + } + return &flashMessage{Kind: parts[0], Message: parts[1]} +} + +func (s *Server) requireMutationAllowed(ctx context.Context) (bool, error) { + summary, err := s.backend.DashboardSummary(ctx) + if err != nil { + return false, err + } + return summary.Sudo.Available, nil +} + +func (s *Server) parseVMCreateForm(r *http.Request) (vmCreateForm, api.VMCreateParams, error) { + if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { + return vmCreateForm{}, api.VMCreateParams{}, err + } + form := vmCreateForm{ + Name: strings.TrimSpace(r.FormValue("name")), + ImageName: strings.TrimSpace(r.FormValue("image_name")), + VCPU: strings.TrimSpace(r.FormValue("vcpu")), + Memory: strings.TrimSpace(r.FormValue("memory")), + SystemOverlaySize: strings.TrimSpace(r.FormValue("system_overlay_size")), + WorkDiskSize: strings.TrimSpace(r.FormValue("work_disk_size")), + NATEnabled: r.FormValue("nat_enabled") == "on", + NoStart: r.FormValue("no_start") == "on", + } + vcpu, err := strconv.Atoi(form.VCPU) + if err != nil { + return form, api.VMCreateParams{}, errors.New("vcpu must be an integer") + } + memory, err := strconv.Atoi(form.Memory) + if err != nil { + return form, api.VMCreateParams{}, errors.New("memory must be an integer") + } + params := api.VMCreateParams{ + Name: form.Name, + ImageName: form.ImageName, + VCPUCount: &vcpu, + MemoryMiB: &memory, + SystemOverlaySize: form.SystemOverlaySize, + WorkDiskSize: form.WorkDiskSize, + NATEnabled: form.NATEnabled, + NoStart: form.NoStart, + } + return form, params, nil +} + +func (s *Server) parseVMSetForm(r *http.Request, vm model.VMRecord) (api.VMSetParams, error) { + if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { + return api.VMSetParams{}, err + } + params := api.VMSetParams{IDOrName: vm.ID} + if raw := strings.TrimSpace(r.FormValue("vcpu")); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil { + return api.VMSetParams{}, errors.New("vcpu must be an integer") + } + if value != vm.Spec.VCPUCount { + params.VCPUCount = &value + } + } + if raw := strings.TrimSpace(r.FormValue("memory")); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil { + return api.VMSetParams{}, errors.New("memory must be an integer") + } + if value != vm.Spec.MemoryMiB { + params.MemoryMiB = &value + } + } + if raw := strings.TrimSpace(r.FormValue("work_disk_size")); raw != "" && raw != model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes) { + params.WorkDiskSize = raw + } + if raw := strings.TrimSpace(r.FormValue("nat_enabled")); raw != "" { + value := raw == "true" + if value != vm.Spec.NATEnabled { + params.NATEnabled = &value + } + } + return params, nil +} + +func (s *Server) parseImageBuildForm(r *http.Request) (imageBuildForm, api.ImageBuildParams, error) { + if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { + return imageBuildForm{}, api.ImageBuildParams{}, err + } + form := imageBuildForm{ + Name: strings.TrimSpace(r.FormValue("name")), + BaseRootfs: strings.TrimSpace(r.FormValue("base_rootfs")), + Size: strings.TrimSpace(r.FormValue("size")), + KernelPath: strings.TrimSpace(r.FormValue("kernel_path")), + InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")), + ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")), + Docker: r.FormValue("docker") == "on", + } + params := api.ImageBuildParams{ + Name: form.Name, + BaseRootfs: form.BaseRootfs, + Size: form.Size, + KernelPath: form.KernelPath, + InitrdPath: form.InitrdPath, + ModulesDir: form.ModulesDir, + Docker: form.Docker, + } + return form, params, nil +} + +func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api.ImageRegisterParams, error) { + if err := s.verifyPOST(nilResponseWriter{}, r); err != nil { + return imageRegisterForm{}, api.ImageRegisterParams{}, err + } + form := imageRegisterForm{ + Name: strings.TrimSpace(r.FormValue("name")), + RootfsPath: strings.TrimSpace(r.FormValue("rootfs_path")), + WorkSeedPath: strings.TrimSpace(r.FormValue("work_seed_path")), + KernelPath: strings.TrimSpace(r.FormValue("kernel_path")), + InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")), + ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")), + PackagesPath: strings.TrimSpace(r.FormValue("packages_path")), + Docker: r.FormValue("docker") == "on", + } + params := api.ImageRegisterParams{ + Name: form.Name, + RootfsPath: form.RootfsPath, + WorkSeedPath: form.WorkSeedPath, + KernelPath: form.KernelPath, + InitrdPath: form.InitrdPath, + ModulesDir: form.ModulesDir, + PackagesPath: form.PackagesPath, + Docker: form.Docker, + } + return form, params, nil +} + +type nilResponseWriter struct{} + +func (nilResponseWriter) Header() http.Header { return http.Header{} } +func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil } +func (nilResponseWriter) WriteHeader(statusCode int) {} + +func writeJSON(w http.ResponseWriter, value any) error { + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(value) +} + +func tailFile(path string, maxLines int) (string, error) { + if strings.TrimSpace(path) == "" { + return "", errors.New("log path is unavailable") + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if maxLines > 0 && len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + return strings.Join(lines, "\n"), nil +} + +func findImage(images []model.Image, id string) model.Image { + for _, image := range images { + if image.ID == id { + return image + } + } + return model.Image{} +} + +func endpointHref(endpoint string) string { + endpoint = strings.TrimSpace(endpoint) + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + return endpoint + } + return "" +} + +func shortID(id string) string { + if len(id) <= 12 { + return id + } + return id[:12] +} + +func sumInt64(values ...int64) int64 { + var total int64 + for _, value := range values { + total += value + } + return total +} + +func formatBytes(bytes int64) string { + const ( + ki = 1024 + mi = ki * 1024 + gi = mi * 1024 + ti = gi * 1024 + ) + switch { + case bytes >= ti: + return fmt.Sprintf("%.1f TiB", float64(bytes)/float64(ti)) + case bytes >= gi: + return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gi)) + case bytes >= mi: + return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mi)) + case bytes >= ki: + return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(ki)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +func formatBytesCompact(bytes int64) string { + const ( + ki = 1024 + mi = ki * 1024 + gi = mi * 1024 + ti = gi * 1024 + ) + type unit struct { + size int64 + suffix string + } + units := []unit{ + {size: ti, suffix: "T"}, + {size: gi, suffix: "G"}, + {size: mi, suffix: "M"}, + {size: ki, suffix: "K"}, + } + abs := bytes + if abs < 0 { + abs = -abs + } + for _, candidate := range units { + if abs >= candidate.size { + value := float64(bytes) / float64(candidate.size) + if math.Abs(value-math.Round(value)) < 0.05 { + return fmt.Sprintf("%.0f%s", math.Round(value), candidate.suffix) + } + return fmt.Sprintf("%.1f%s", value, candidate.suffix) + } + } + return fmt.Sprintf("%dB", bytes) +} + +func percentOf(used, total any) int { + totalValue := numericValue(total) + if totalValue <= 0 { + return 0 + } + usedValue := numericValue(used) + percent := int(math.Round((usedValue / totalValue) * 100)) + switch { + case percent < 0: + return 0 + case percent > 100: + return 100 + default: + return percent + } +} + +func numericValue(value any) float64 { + switch typed := value.(type) { + case int: + return float64(typed) + case int8: + return float64(typed) + case int16: + return float64(typed) + case int32: + return float64(typed) + case int64: + return float64(typed) + case uint: + return float64(typed) + case uint8: + return float64(typed) + case uint16: + return float64(typed) + case uint32: + return float64(typed) + case uint64: + return float64(typed) + case float32: + return float64(typed) + case float64: + return typed + default: + return 0 + } +} + +func formatPercent(value float64) string { + return fmt.Sprintf("%.1f%%", value) +} + +func relativeTime(ts time.Time) string { + if ts.IsZero() { + return "-" + } + delta := time.Since(ts) + switch { + case delta < time.Minute: + return "just now" + case delta < time.Hour: + return fmt.Sprintf("%d minutes ago", int(delta.Minutes())) + case delta < 24*time.Hour: + return fmt.Sprintf("%d hours ago", int(delta.Hours())) + default: + return fmt.Sprintf("%d days ago", int(delta.Hours()/24)) + } +} + +func formatBool(value bool) string { + if value { + return "yes" + } + return "no" +} + +func stateClass(state model.VMState) string { + switch state { + case model.VMStateRunning: + return "running" + case model.VMStateStopped: + return "stopped" + case model.VMStateError: + return "error" + default: + return "created" + } +} diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go new file mode 100644 index 0000000..bbe6f0c --- /dev/null +++ b/internal/webui/server_test.go @@ -0,0 +1,231 @@ +package webui + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "banger/internal/api" + "banger/internal/model" + "banger/internal/paths" +) + +type fakeBackend struct { + layout paths.Layout + config model.DaemonConfig + summary api.DashboardSummary + vms []model.VMRecord + images []model.Image + vm model.VMRecord + image model.Image + ports api.VMPortsResult + createOp api.VMCreateOperation + buildOp api.ImageBuildOperation +} + +func (f fakeBackend) Config() model.DaemonConfig { return f.config } +func (f fakeBackend) Layout() paths.Layout { return f.layout } +func (f fakeBackend) DashboardSummary(context.Context) (api.DashboardSummary, error) { + return f.summary, nil +} +func (f fakeBackend) ListVMs(context.Context) ([]model.VMRecord, error) { return f.vms, nil } +func (f fakeBackend) FindVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } +func (f fakeBackend) GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error) { + return f.vm, f.vm.Stats, nil +} +func (f fakeBackend) BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error) { + return f.createOp, nil +} +func (f fakeBackend) VMCreateStatus(context.Context, string) (api.VMCreateOperation, error) { + return f.createOp, nil +} +func (f fakeBackend) StartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } +func (f fakeBackend) StopVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } +func (f fakeBackend) RestartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } +func (f fakeBackend) DeleteVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } +func (f fakeBackend) SetVM(context.Context, api.VMSetParams) (model.VMRecord, error) { + return f.vm, nil +} +func (f fakeBackend) PortsVM(context.Context, string) (api.VMPortsResult, error) { return f.ports, nil } +func (f fakeBackend) ListImages(context.Context) ([]model.Image, error) { return f.images, nil } +func (f fakeBackend) FindImage(context.Context, string) (model.Image, error) { return f.image, nil } +func (f fakeBackend) BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error) { + return f.buildOp, nil +} +func (f fakeBackend) ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error) { + return f.buildOp, nil +} +func (f fakeBackend) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) { + return f.image, nil +} +func (f fakeBackend) PromoteImage(context.Context, string) (model.Image, error) { return f.image, nil } +func (f fakeBackend) DeleteImage(context.Context, string) (model.Image, error) { return f.image, nil } + +func TestDashboardPageRendersSummaryAndTables(t *testing.T) { + backend := fakeBackend{ + layout: paths.Layout{StateDir: t.TempDir()}, + config: model.DaemonConfig{SSHKeyPath: "/tmp/id"}, + summary: api.DashboardSummary{ + Host: api.HostSummary{CPUCount: 8, TotalMemoryBytes: 16 << 30, StateFilesystemFreeBytes: 9 << 30, StateFilesystemTotalBytes: 20 << 30}, + Sudo: api.SudoStatus{Available: true, Command: "sudo -v"}, + Banger: api.BangerSummary{ + VMCount: 1, RunningVMCount: 1, ImageCount: 1, ManagedImageCount: 1, ConfiguredVCPUCount: 2, + ConfiguredMemoryBytes: 1 << 30, + ConfiguredDiskBytes: 8 << 30, + UsedWorkDiskBytes: 3 << 30, + }, + }, + vms: []model.VMRecord{{ID: "vm-1", Name: "smth", State: model.VMStateRunning, CreatedAt: model.Now(), Runtime: model.VMRuntime{GuestIP: "172.16.0.2"}, Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}}}, + images: []model.Image{{ID: "img-1", Name: "void-exp", Managed: true, RootfsPath: "/tmp/rootfs.ext4", CreatedAt: model.Now()}}, + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + NewHandler(backend).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"vCPU", "2 / 8", "1G / 16G", "8G / 20G", "9G free", "smth", "void-exp", "Create VM"} { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q\n%s", want, body) + } + } + if len(rec.Result().Cookies()) == 0 { + t.Fatal("expected csrf cookie to be set") + } +} + +func TestVMActionRejectsMissingCSRF(t *testing.T) { + backend := fakeBackend{ + layout: paths.Layout{StateDir: t.TempDir()}, + summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, + vm: model.VMRecord{ID: "vm-1", Name: "smth"}, + } + req := httptest.NewRequest(http.MethodPost, "/vms/vm-1/start", strings.NewReader("")) + req.Header.Set("Origin", "http://example.com") + rec := httptest.NewRecorder() + NewHandler(backend).ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + +func TestFSAPIListsEntries(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "rootfs.ext4"), []byte("data"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + backend := fakeBackend{ + layout: paths.Layout{StateDir: dir}, + summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, + } + + req := httptest.NewRequest(http.MethodGet, "/api/fs?path="+url.QueryEscape(dir)+"&kind=file", nil) + rec := httptest.NewRecorder() + NewHandler(backend).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + data, err := io.ReadAll(rec.Body) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + body := string(data) + for _, want := range []string{"rootfs.ext4", "nested"} { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q\n%s", want, body) + } + } +} + +func TestVMShowPageRendersRunningActions(t *testing.T) { + backend := fakeBackend{ + layout: paths.Layout{StateDir: t.TempDir()}, + config: model.DaemonConfig{SSHKeyPath: "/tmp/id"}, + summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true, Command: "sudo -v"}}, + vm: model.VMRecord{ + ID: "vm-1", + Name: "smth", + State: model.VMStateRunning, + Runtime: model.VMRuntime{ + GuestIP: "172.16.0.2", + }, + Spec: model.VMSpec{ + VCPUCount: 2, + MemoryMiB: 1024, + WorkDiskSizeBytes: 8 << 30, + }, + Stats: model.VMStats{ + CPUPercent: 12.5, + RSSBytes: 64 << 20, + SystemOverlayBytes: 2 << 20, + WorkDiskBytes: 32 << 20, + }, + }, + image: model.Image{ID: "img-1", Name: "void-exp"}, + ports: api.VMPortsResult{ + Name: "smth", + Ports: []api.VMPort{ + {Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "opencode"}, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/vms/vm-1", nil) + rec := httptest.NewRecorder() + NewHandler(backend).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"Stop", "Restart", "href=\"http://172.16.0.2:4096\"", "data-confirm=\"Stop VM smth?\"", "data-confirm=\"Delete VM smth?\""} { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q\n%s", want, body) + } + } + for _, unwanted := range []string{"opencode attach", "root@172.16.0.2"} { + if strings.Contains(body, unwanted) { + t.Fatalf("body unexpectedly contains %q\n%s", unwanted, body) + } + } +} + +func TestVMListShowsImageNameAndLink(t *testing.T) { + backend := fakeBackend{ + layout: paths.Layout{StateDir: t.TempDir()}, + summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, + vms: []model.VMRecord{ + {ID: "vm-1", Name: "smth", ImageID: "img-1", State: model.VMStateRunning, CreatedAt: model.Now(), Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}}, + }, + images: []model.Image{ + {ID: "img-1", Name: "void-exp"}, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/vms", nil) + rec := httptest.NewRecorder() + NewHandler(backend).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{">void-exp", "href=\"/images/img-1\""} { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q\n%s", want, body) + } + } +} diff --git a/internal/webui/templates/base.html b/internal/webui/templates/base.html new file mode 100644 index 0000000..2fb2473 --- /dev/null +++ b/internal/webui/templates/base.html @@ -0,0 +1,124 @@ +{{define "page"}} + + + + + + {{.Title}} · banger + + + +
+
+
+

Local Control Plane

+

banger

+
+ +
+ + {{if not .MutationAllowed}} + + {{end}} + + {{if .Flash}} + + {{end}} + +
+
+
+

vCPU

+ {{.Summary.Banger.ConfiguredVCPUCount}} / {{.Summary.Host.CPUCount}} +
+ +
+ {{percentOf .Summary.Banger.ConfiguredVCPUCount .Summary.Host.CPUCount}}% allocated + {{.Summary.Banger.RunningVMCount}} running +
+
+
+
+

Memory

+ {{formatBytesCompact .Summary.Banger.ConfiguredMemoryBytes}} / {{formatBytesCompact .Summary.Host.TotalMemoryBytes}} +
+ +
+ {{percentOf .Summary.Banger.ConfiguredMemoryBytes .Summary.Host.TotalMemoryBytes}}% allocated + {{formatBytesCompact .Summary.Banger.RunningRSSBytes}} RSS live +
+
+
+
+

Disk

+ {{formatBytesCompact .Summary.Banger.ConfiguredDiskBytes}} / {{formatBytesCompact .Summary.Host.StateFilesystemTotalBytes}} +
+ +
+ {{formatBytesCompact .Summary.Host.StateFilesystemFreeBytes}} free + {{formatBytesCompact (sumInt64 .Summary.Banger.UsedSystemOverlayBytes .Summary.Banger.UsedWorkDiskBytes)}} actual +
+
+
+
+ {{.Summary.Banger.RunningVMCount}} / {{.Summary.Banger.VMCount}} running + {{.Summary.Banger.ImageCount}} images + {{.Summary.Banger.ManagedImageCount}} managed + {{formatPercent .Summary.Banger.RunningCPUPercent}} live CPU +
+ +
+
+

{{.Title}}

+
+ {{.BodyHTML}} +
+
+ + +
+
+

Roots

+
+ {{range .PickerRoots}} + + {{end}} +
+
+
+
+ / +
+ + +
+
+

Choose a host path. Directories open in place; files select immediately.

+
+
+
+
+ + + + +{{end}} + +{{define "csrf_field"}} + +{{end}} diff --git a/internal/webui/templates/dashboard.html b/internal/webui/templates/dashboard.html new file mode 100644 index 0000000..aa18698 --- /dev/null +++ b/internal/webui/templates/dashboard.html @@ -0,0 +1,65 @@ +{{define "dashboard_content"}} +
+
+
+

Virtual Machines

+ Create VM +
+ + + + + + + + + + + + {{range .VMs}} + + + + + + + + {{else}} + + {{end}} + +
NameStateIPSpecCreated
{{.Name}}{{.State}}{{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}}{{.Spec.VCPUCount}} vCPU / {{.Spec.MemoryMiB}} MiB / {{formatBytes .Spec.WorkDiskSizeBytes}}{{relativeTime .CreatedAt}}
No VMs yet.
+
+
+
+

Images

+
+ Register + Build +
+
+ + + + + + + + + + + {{range .Images}} + + + + + + + {{else}} + + {{end}} + +
NameManagedRootfsCreated
{{.Name}}{{formatBool .Managed}}{{.RootfsPath}}{{relativeTime .CreatedAt}}
No images registered.
+
+
+{{end}} diff --git a/internal/webui/templates/error.html b/internal/webui/templates/error.html new file mode 100644 index 0000000..71e45b1 --- /dev/null +++ b/internal/webui/templates/error.html @@ -0,0 +1,3 @@ +{{define "error_content"}} +
{{.ErrorMessage}}
+{{end}} diff --git a/internal/webui/templates/images.html b/internal/webui/templates/images.html new file mode 100644 index 0000000..0b4fe3b --- /dev/null +++ b/internal/webui/templates/images.html @@ -0,0 +1,182 @@ +{{define "image_list_content"}} +
+

Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.

+ +
+ + + + + + + + + + + + {{range .Images}} + + + + + + + + {{else}} + + {{end}} + +
NameManagedDockerRootfsCreated
{{.Name}}{{formatBool .Managed}}{{formatBool .Docker}}{{.RootfsPath}}{{relativeTime .CreatedAt}}
No images registered.
+{{end}} + +{{define "image_build_content"}} +

Build a managed image from a base rootfs, then redirect into the async build progress view.

+{{if .ErrorMessage}} +
{{.ErrorMessage}}
+{{end}} +
+ {{template "csrf_field" .}} + + + + + + + +
+ Cancel + +
+
+{{end}} + +{{define "image_register_content"}} +

Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.

+{{if .ErrorMessage}} +
{{.ErrorMessage}}
+{{end}} +
+ {{template "csrf_field" .}} + + + + + + + + +
+ Cancel + +
+
+{{end}} + +{{define "image_show_content"}} +
+
+

{{.Image.Name}}

+
+
ID
{{.Image.ID}}
+
Managed
{{formatBool .Image.Managed}}
+
Docker
{{formatBool .Image.Docker}}
+
Used By
{{.ImageUsers}} VM(s)
+
+
+
+

Artifacts

+
+
Rootfs
{{.Image.RootfsPath}}
+
Work Seed
{{if .Image.WorkSeedPath}}{{.Image.WorkSeedPath}}{{else}}-{{end}}
+
Kernel
{{.Image.KernelPath}}
+
Initrd
{{if .Image.InitrdPath}}{{.Image.InitrdPath}}{{else}}-{{end}}
+
Modules
{{if .Image.ModulesDir}}{{.Image.ModulesDir}}{{else}}-{{end}}
+
+
+
+

Lifecycle

+
+
Created
{{relativeTime .Image.CreatedAt}}
+
Updated
{{relativeTime .Image.UpdatedAt}}
+
Packages
{{if .Image.PackagesPath}}{{.Image.PackagesPath}}{{else}}-{{end}}
+
Artifact Dir
{{if .Image.ArtifactDir}}{{.Image.ArtifactDir}}{{else}}-{{end}}
+
+
+
+ +
+ {{if not .Image.Managed}} +
{{template "csrf_field" .}}
+ {{end}} +
{{template "csrf_field" .}}
+
+{{end}} diff --git a/internal/webui/templates/operation.html b/internal/webui/templates/operation.html new file mode 100644 index 0000000..87ff45e --- /dev/null +++ b/internal/webui/templates/operation.html @@ -0,0 +1,20 @@ +{{define "operation_content"}} +
+

{{if eq .OperationKind "vm"}}VM readiness{{else}}Managed image build{{end}}

+ {{if .VMCreateOperation}} +

{{.VMCreateOperation.Stage}}

+

{{.VMCreateOperation.Detail}}

+

{{.VMCreateOperation.Error}}

+ {{end}} + {{if .ImageBuildOperation}} +

{{.ImageBuildOperation.Stage}}

+

{{.ImageBuildOperation.Detail}}

+

{{.ImageBuildOperation.Error}}

+ {{end}} + {{if .OperationLogPath}} +

Build log: {{.OperationLogPath}}

+ {{else}} +

+ {{end}} +
+{{end}} diff --git a/internal/webui/templates/vms.html b/internal/webui/templates/vms.html new file mode 100644 index 0000000..886e44c --- /dev/null +++ b/internal/webui/templates/vms.html @@ -0,0 +1,191 @@ +{{define "vm_list_content"}} +
+

Inspect lifecycle, capacity, and reachability for every VM.

+ Create VM +
+ + + + + + + + + + + + + + + {{range .VMs}} + + + + + + + + + + + {{else}} + + {{end}} + +
NameStateImageIPvCPUMemoryDiskCreated
{{.Name}}{{.State}}{{$image := findImage $.Images .ImageID}}{{if $image.ID}}{{$image.Name}}{{else}}{{shortID .ImageID}}{{end}}{{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}}{{.Spec.VCPUCount}}{{.Spec.MemoryMiB}} MiB{{formatBytes .Spec.WorkDiskSizeBytes}}{{relativeTime .CreatedAt}}
No VMs registered.
+{{end}} + +{{define "vm_new_content"}} +

Create a VM and wait until the guest is fully ready. The browser will follow live create progress automatically.

+{{if .ErrorMessage}} +
{{.ErrorMessage}}
+{{end}} +
+ {{template "csrf_field" .}} + + + + + + + + +
+ Cancel + +
+
+{{end}} + +{{define "vm_show_content"}} +
+
+

{{.VM.Name}}

+
+
ID
{{.VM.ID}}
+
Image
{{if .VMImage.ID}}{{.VMImage.Name}}{{else}}{{shortID .VM.ImageID}}{{end}}
+
State
{{.VM.State}}
+
Guest IP
{{if .VM.Runtime.GuestIP}}{{.VM.Runtime.GuestIP}}{{else}}-{{end}}
+
Created
{{relativeTime .VM.CreatedAt}}
+
+
+
+

Configured Spec

+
+
vCPU
{{.VM.Spec.VCPUCount}}
+
Memory
{{.VM.Spec.MemoryMiB}} MiB
+
Disk
{{formatBytes .VM.Spec.WorkDiskSizeBytes}}
+
NAT
{{formatBool .VM.Spec.NATEnabled}}
+
+
+
+

Current Usage

+
+
CPU
{{formatPercent .VMStats.CPUPercent}}
+
RSS
{{formatBytes .VMStats.RSSBytes}}
+
Overlay
{{formatBytes .VMStats.SystemOverlayBytes}}
+
Work Disk
{{formatBytes .VMStats.WorkDiskBytes}}
+
+
+
+ +
+

Actions

+ Logs +
+
+ {{if eq .VM.State "running"}} +
{{template "csrf_field" .}}
+
{{template "csrf_field" .}}
+ {{else}} +
{{template "csrf_field" .}}
+ {{end}} +
{{template "csrf_field" .}}
+
+ +
+
+

Listening Ports

+ {{if .VMPortsError}} +

{{.VMPortsError}}

+ {{else}} + + + + + + {{range .VMPorts.Ports}} + + + + + + {{else}} + + {{end}} + +
PortProcessEndpoint
{{.Proto}}/{{.Port}}{{if .Process}}{{.Process}}{{else}}-{{end}}{{if .Endpoint}}{{if endpointHref .Endpoint}}{{.Endpoint}}{{else}}{{.Endpoint}}{{end}}{{else}}-{{end}}
No host-reachable listeners reported.
+ {{end}} +
+
+

Update Settings

+
+ {{template "csrf_field" .}} + + + + +
+ Cancel + +
+
+
+
+{{end}} + +{{define "vm_logs_content"}} +
+

Showing the last 200 lines from the Firecracker log.

+
+ + Refresh +
+
+
{{.LogText}}
+{{end}} From 01c7cb5e6594db18c978bc060dc681279de3eff1 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 21 Mar 2026 17:22:57 -0300 Subject: [PATCH 005/257] Reorganize the source checkout layout Separate tracked source from generated artifacts so the repo root stops accumulating helper scripts, manifests, and local runtime outputs. Move manual shell entrypoints under scripts/, manifests under config/, and the Firecracker API reference under docs/reference/. Make build and runtimebundle now target build/bin, build/runtime, and build/dist as the canonical source-checkout paths. Update runtime discovery, helper scripts, tests, and docs to follow the new layout while keeping legacy source-checkout runtime fallbacks for existing local bundles during migration. Validated with bash -n on the moved scripts, make build, and GOCACHE=/tmp/banger-gocache go test ./.... --- .gitignore | 1 + AGENTS.md | 38 +++---- Makefile | 55 +++++----- README.md | 102 ++++++++++-------- cmd/runtimebundle/main.go | 16 ++- packages.apt => config/packages.apt | 0 packages.void => config/packages.void | 0 .../runtime-bundle.toml | 2 +- .../reference/firecracker-api.yaml | 0 examples/void-exp.config.toml | 12 +-- internal/cli/cli_test.go | 2 +- internal/daemon/daemon_test.go | 10 +- internal/paths/paths.go | 18 +++- internal/paths/paths_test.go | 22 +++- scripts/bench-create.sh | 8 +- customize.sh => scripts/customize.sh | 19 ++-- interactive.sh => scripts/interactive.sh | 19 ++-- packages.sh => scripts/lib/packages.sh | 5 +- .../make-rootfs-void.sh | 37 ++++--- make-rootfs.sh => scripts/make-rootfs.sh | 30 +++--- .../make-void-kernel.sh | 19 ++-- .../register-void-image.sh | 17 +-- verify.sh => scripts/verify.sh | 50 ++++++--- 23 files changed, 296 insertions(+), 186 deletions(-) rename packages.apt => config/packages.apt (100%) rename packages.void => config/packages.void (100%) rename runtime-bundle.toml => config/runtime-bundle.toml (92%) rename firecracker-api.yaml => docs/reference/firecracker-api.yaml (100%) rename customize.sh => scripts/customize.sh (96%) rename interactive.sh => scripts/interactive.sh (93%) rename packages.sh => scripts/lib/packages.sh (92%) rename make-rootfs-void.sh => scripts/make-rootfs-void.sh (93%) rename make-rootfs.sh => scripts/make-rootfs.sh (57%) rename make-void-kernel.sh => scripts/make-void-kernel.sh (95%) rename register-void-image.sh => scripts/register-void-image.sh (82%) rename verify.sh => scripts/verify.sh (85%) diff --git a/.gitignore b/.gitignore index 1a13dc5..4aad341 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ state/ +/build/ /runtime/ /dist/ /banger diff --git a/AGENTS.md b/AGENTS.md index 2f547b3..5cfea46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,28 +4,28 @@ - `cmd/banger` and `cmd/bangerd` are the primary user-facing entrypoints. - `internal/` contains the daemon, CLI, RPC, storage, Firecracker, and system integration code. - The VM lifecycle is now organized around daemon capabilities plus a structured guest-config builder. New host-integrated VM features should plug into that Go path instead of adding more one-off branches through `internal/daemon/vm.go`. -- `customize.sh`, `make-rootfs.sh`, and `interactive.sh` remain as manual rootfs/customization helpers; normal VM lifecycle, NAT, `.vm` DNS, and daemon-driven image builds are handled by the Go control plane. -- Source checkouts use a generated `./runtime/` bundle for Firecracker, kernels, modules, rootfs images, and helper copies. Bundle defaults come from `./runtime/bundle.json` when present. Those runtime artifacts are not meant to be tracked directly in Git. +- `scripts/customize.sh`, `scripts/make-rootfs.sh`, and `scripts/interactive.sh` remain as manual rootfs/customization helpers; normal VM lifecycle, NAT, `.vm` DNS, and daemon-driven image builds are handled by the Go control plane. +- Source checkouts use a generated `./build/runtime/` bundle for Firecracker, kernels, modules, rootfs images, and helper copies. Bundle defaults come from `./build/runtime/bundle.json` when present. Those runtime artifacts are not meant to be tracked directly in Git. - The daemon keeps state under XDG directories rather than the old repo-local `state/` layout. ## Build, Test, and Development Commands -- `make build` builds `./banger`, `./bangerd`, and the bundled `./runtime/banger-vsock-agent` guest helper. +- `make build` builds `./build/bin/banger`, `./build/bin/bangerd`, and the bundled `./build/runtime/banger-vsock-agent` guest helper. - `make bench-create` benchmarks `vm create` and first-SSH readiness on the current host. -- `make runtime-bundle` bootstraps `./runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `runtime-bundle.toml` is only a template. -- `make void-kernel` downloads and stages a Void `linux6.12` kernel under `./runtime/void-kernel`, including extracted `vmlinux`, raw `vmlinuz`, a matching generated `initramfs`, config, and matching modules. -- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./runtime/`; it prefers staged `./runtime/void-kernel` modules when present, but does not replace the default Debian path or teach `banger image build` about Void. +- `make runtime-bundle` bootstraps `./build/runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `config/runtime-bundle.toml` is only a template. +- `make void-kernel` downloads and stages a Void `linux6.12` kernel under `./build/runtime/void-kernel`, including extracted `vmlinux`, raw `vmlinuz`, a matching generated `initramfs`, config, and matching modules. +- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./build/runtime/`; it prefers staged `./build/runtime/void-kernel` modules when present, but does not replace the default Debian path or teach `banger image build` about Void. - `make verify-void` registers `void-exp` and runs the normal smoke test against that image. - `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set. -- `./banger vm create --name testbox` creates and starts a VM. -- `./banger vm create` now blocks until the guest reaches the daemon's default readiness checks and shows live progress stages on TTY stderr while it waits. -- `./banger vm ssh testbox` connects to a running guest using the runtime bundle SSH key and reminds the user if the VM is still running when the session exits. -- `./banger vm stop testbox` stops a VM while preserving its disks. -- `./banger vm stop vm-a vm-b vm-c` and `./banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. -- `./banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. -- `./banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void. +- `./build/bin/banger vm create --name testbox` creates and starts a VM. +- `./build/bin/banger vm create` now blocks until the guest reaches the daemon's default readiness checks and shows live progress stages on TTY stderr while it waits. +- `./build/bin/banger vm ssh testbox` connects to a running guest using the runtime bundle SSH key and reminds the user if the VM is still running when the session exits. +- `./build/bin/banger vm stop testbox` stops a VM while preserving its disks. +- `./build/bin/banger vm stop vm-a vm-b vm-c` and `./build/bin/banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. +- `./build/bin/banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. +- `./build/bin/banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void. - `bangerd` now also serves a localhost web UI on `http://127.0.0.1:7777` by default unless `web_listen_addr = ""` disables it; the UI uses server-rendered templates, polls async VM/image operations, and keeps image path selection on the host via a server-side file picker. - `make test` runs `go test ./...`. -- `./verify.sh` runs the smoke test for the Go VM workflow. +- `./scripts/verify.sh` runs the smoke test for the Go VM workflow. ## Coding Style & Naming Conventions - Go code should stay small, direct, and standard-library-first unless there is a clear reason otherwise. @@ -35,15 +35,15 @@ ## Testing Guidelines - Primary automated coverage is `go test ./...`. -- Manual verification for VM lifecycle changes: `./banger vm create`, confirm SSH access, then stop/delete the VM. -- For host-integration changes, run `./banger doctor` as a quick readiness check before the live VM smoke. +- Manual verification for VM lifecycle changes: `./build/bin/banger vm create`, confirm SSH access, then stop/delete the VM. +- For host-integration changes, run `./build/bin/banger doctor` as a quick readiness check before the live VM smoke. - The web UI follows the same sudo model as the CLI path: bangerd stays unprivileged and privileged writes only work when `sudo -v` is already warm or sudo is passwordless. -- Rebuilt images now include `mise`, `opencode`, a host-reachable default `opencode` server service on guest TCP port `4096`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./runtime/rootfs-docker.ext4` or another base image to pick it up. -- The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, a guest network bootstrap sourced from the kernel `ip=` cmdline, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, the default host-reachable `opencode` server service on guest TCP port `4096`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. When `./runtime/void-kernel/` exists, the Void image registration path expects a complete staged Void kernel, initramfs, and modules tree and points `void-exp` at it. Keep further baked-in tooling deliberate and user-driven. +- Rebuilt images now include `mise`, `opencode`, a host-reachable default `opencode` server service on guest TCP port `4096`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./build/runtime/rootfs-docker.ext4` or another base image to pick it up. +- The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, a guest network bootstrap sourced from the kernel `ip=` cmdline, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, the default host-reachable `opencode` server service on guest TCP port `4096`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. When `./build/runtime/void-kernel/` exists, the Void image registration path expects a complete staged Void kernel, initramfs, and modules tree and points `void-exp` at it. Keep further baked-in tooling deliberate and user-driven. - Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. Older managed images may take one slower create to refresh seeded SSH access before they rejoin the fast path. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. - The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks. - If you add a new operational workflow, document how to exercise it in `README.md`. -- For NAT changes, verify both guest outbound access and host rule cleanup, for example with `./verify.sh --nat`. +- For NAT changes, verify both guest outbound access and host rule cleanup, for example with `./scripts/verify.sh --nat`. ## Commit & Pull Request Guidelines - Git history uses short, imperative subjects. diff --git a/Makefile b/Makefile index 36ee25c..50a17fa 100644 --- a/Makefile +++ b/Makefile @@ -8,10 +8,14 @@ BINDIR ?= $(PREFIX)/bin LIBDIR ?= $(PREFIX)/lib RUNTIMEDIR ?= $(LIBDIR)/banger DESTDIR ?= -RUNTIME_MANIFEST ?= runtime-bundle.toml -RUNTIME_SOURCE_DIR ?= runtime -RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz -BINARIES := banger bangerd +BUILD_DIR ?= build +BUILD_BIN_DIR ?= $(BUILD_DIR)/bin +RUNTIME_MANIFEST ?= config/runtime-bundle.toml +RUNTIME_SOURCE_DIR ?= $(BUILD_DIR)/runtime +RUNTIME_ARCHIVE ?= $(BUILD_DIR)/dist/banger-runtime.tar.gz +BANGER_BIN ?= $(BUILD_BIN_DIR)/banger +BANGERD_BIN ?= $(BUILD_BIN_DIR)/bangerd +BINARIES := $(BANGER_BIN) $(BANGERD_BIN) RUNTIME_HELPERS := $(RUNTIME_SOURCE_DIR)/banger-vsock-agent GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort) RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen banger-vsock-agent @@ -29,8 +33,8 @@ VOID_VM_NAME ?= void-dev help: @printf '%s\n' \ 'Targets:' \ - ' make build Build ./banger and ./bangerd' \ - ' make runtime-bundle Fetch and unpack ./runtime from the archive referenced by $(RUNTIME_MANIFEST)' \ + ' make build Build ./build/bin/banger and ./build/bin/bangerd' \ + ' make runtime-bundle Fetch and unpack ./build/runtime from the archive referenced by $(RUNTIME_MANIFEST)' \ ' make runtime-package Package $(RUNTIME_SOURCE_DIR) into $(RUNTIME_ARCHIVE) and print its SHA256' \ ' make bench-create Benchmark vm create and SSH readiness with scripts/bench-create.sh' \ ' make install Build and install binaries plus the runtime bundle into $(DESTDIR)$(BINDIR) and $(DESTDIR)$(RUNTIMEDIR)' \ @@ -38,20 +42,22 @@ help: ' make fmt Format Go sources under cmd/ and internal/' \ ' make tidy Run go mod tidy' \ ' make clean Remove built Go binaries' \ - ' make rootfs Rebuild the source-checkout default Debian rootfs image in ./runtime' \ - ' make void-kernel Download and stage a Void kernel, initramfs, and modules under ./runtime/void-kernel' \ - ' make rootfs-void Build an experimental Void Linux rootfs and work-seed in ./runtime' \ + ' make rootfs Rebuild the source-checkout default Debian rootfs image in ./build/runtime' \ + ' make void-kernel Download and stage a Void kernel, initramfs, and modules under ./build/runtime/void-kernel' \ + ' make rootfs-void Build an experimental Void Linux rootfs and work-seed in ./build/runtime' \ ' make void-register Register or update the experimental Void image as $(VOID_IMAGE_NAME)' \ ' make void-vm Register the experimental Void image and create a VM named $(VOID_VM_NAME)' \ - ' make verify-void Register the experimental Void image and run verify.sh against it' + ' make verify-void Register the experimental Void image and run scripts/verify.sh against it' build: $(BINARIES) $(RUNTIME_HELPERS) -banger: $(GO_SOURCES) go.mod go.sum - $(GO) build -o ./banger ./cmd/banger +$(BANGER_BIN): $(GO_SOURCES) go.mod go.sum + mkdir -p "$(BUILD_BIN_DIR)" + $(GO) build -o "$(BANGER_BIN)" ./cmd/banger -bangerd: $(GO_SOURCES) go.mod go.sum - $(GO) build -o ./bangerd ./cmd/bangerd +$(BANGERD_BIN): $(GO_SOURCES) go.mod go.sum + mkdir -p "$(BUILD_BIN_DIR)" + $(GO) build -o "$(BANGERD_BIN)" ./cmd/bangerd $(RUNTIME_SOURCE_DIR)/banger-vsock-agent: $(GO_SOURCES) go.mod go.sum mkdir -p "$(RUNTIME_SOURCE_DIR)" @@ -67,7 +73,8 @@ tidy: $(GO) mod tidy clean: - rm -f ./banger ./bangerd + rm -rf "$(BUILD_BIN_DIR)" + rm -f "$(RUNTIME_SOURCE_DIR)/banger-vsock-agent" runtime-bundle: $(GO) run ./cmd/runtimebundle fetch --manifest "$(RUNTIME_MANIFEST)" --out "$(RUNTIME_SOURCE_DIR)" @@ -76,7 +83,7 @@ runtime-package: $(GO) run ./cmd/runtimebundle package --manifest "$(RUNTIME_MANIFEST)" --runtime-dir "$(RUNTIME_SOURCE_DIR)" --out "$(RUNTIME_ARCHIVE)" bench-create: build - bash ./scripts/bench-create.sh $(ARGS) + BANGER_BIN="$(abspath $(BANGER_BIN))" bash ./scripts/bench-create.sh $(ARGS) check-runtime: @test -d "$(RUNTIME_SOURCE_DIR)" || { echo "missing runtime bundle directory: $(RUNTIME_SOURCE_DIR); run 'make runtime-bundle'" >&2; exit 1; } @@ -89,8 +96,8 @@ install: build check-runtime mkdir -p "$(DESTDIR)$(RUNTIMEDIR)" mkdir -p "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/boot" mkdir -p "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/lib/modules" - $(INSTALL) -m 0755 ./banger "$(DESTDIR)$(BINDIR)/banger" - $(INSTALL) -m 0755 ./bangerd "$(DESTDIR)$(BINDIR)/bangerd" + $(INSTALL) -m 0755 "$(BANGER_BIN)" "$(DESTDIR)$(BINDIR)/banger" + $(INSTALL) -m 0755 "$(BANGERD_BIN)" "$(DESTDIR)$(BINDIR)/bangerd" @for path in $(RUNTIME_EXECUTABLES); do \ $(INSTALL) -m 0755 "$(RUNTIME_SOURCE_DIR)/$$path" "$(DESTDIR)$(RUNTIMEDIR)/$$path"; \ done @@ -106,19 +113,19 @@ install: build check-runtime cp -a "$(RUNTIME_SOURCE_DIR)/$(RUNTIME_MODULES_DIR)" "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/lib/modules/" rootfs: - BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs.sh + BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./scripts/make-rootfs.sh void-kernel: - BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-void-kernel.sh + BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./scripts/make-void-kernel.sh rootfs-void: - BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./make-rootfs-void.sh + BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./scripts/make-rootfs-void.sh void-register: build - BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" VOID_IMAGE_NAME="$(VOID_IMAGE_NAME)" BANGER_BIN="$(abspath ./banger)" ./register-void-image.sh + BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" VOID_IMAGE_NAME="$(VOID_IMAGE_NAME)" BANGER_BIN="$(abspath $(BANGER_BIN))" ./scripts/register-void-image.sh void-vm: void-register - ./banger vm create --image "$(VOID_IMAGE_NAME)" --name "$(VOID_VM_NAME)" + "$(abspath $(BANGER_BIN))" vm create --image "$(VOID_IMAGE_NAME)" --name "$(VOID_VM_NAME)" verify-void: void-register - ./verify.sh --image "$(VOID_IMAGE_NAME)" + BANGER_BIN="$(abspath $(BANGER_BIN))" ./scripts/verify.sh --image "$(VOID_IMAGE_NAME)" diff --git a/README.md b/README.md index 18c59d1..b6b9235 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ assuming one workstation layout. ## Runtime Bundle Runtime artifacts are no longer tracked directly in Git. Source checkouts use a -generated `./runtime/` bundle, while installed binaries use +generated `./build/runtime/` bundle, while installed binaries use `$(prefix)/lib/banger`. The bundle contains: @@ -34,30 +34,30 @@ The bundle contains: - the helper scripts used by manual customization and installs Bootstrap a source checkout from a local or published runtime archive. The -checked-in [`runtime-bundle.toml`](/home/thales/projects/personal/banger/runtime-bundle.toml) +checked-in [`config/runtime-bundle.toml`](/home/thales/projects/personal/banger/config/runtime-bundle.toml) is a template and intentionally ships with empty `url` and `sha256`. If you need to create a local archive first, do that from a checkout or machine -that already has a populated `./runtime/` tree: +that already has a populated `./build/runtime/` tree: ```bash make runtime-package -cp dist/banger-runtime.tar.gz /path/to/fresh-checkout/dist/ +cp build/dist/banger-runtime.tar.gz /path/to/fresh-checkout/build/dist/ ``` In the fresh checkout: ```bash -cp runtime-bundle.toml runtime-bundle.local.toml +cp config/runtime-bundle.toml config/runtime-bundle.local.toml ``` -Edit `runtime-bundle.local.toml` to point at the staged archive and checksum: +Edit `config/runtime-bundle.local.toml` to point at the staged archive and checksum: ```toml -url = "./dist/banger-runtime.tar.gz" +url = "./build/dist/banger-runtime.tar.gz" sha256 = "" ``` -Then bootstrap `./runtime/` with the local manifest copy: +Then bootstrap `./build/runtime/` with the local manifest copy: ```bash -make runtime-bundle RUNTIME_MANIFEST=runtime-bundle.local.toml +make runtime-bundle RUNTIME_MANIFEST=config/runtime-bundle.local.toml ``` `url` may be a relative path, absolute path, `file:///...` URL, or HTTP(S) @@ -68,8 +68,19 @@ URL. `make install` will not fetch artifacts for you. make build ``` -Run `make build` after `./runtime/` has been bootstrapped. It also rebuilds the -bundled `banger-vsock-agent` guest helper in `./runtime/`. +Run `make build` after `./build/runtime/` has been bootstrapped. It writes +`./build/bin/banger`, `./build/bin/bangerd`, and refreshes the bundled +`banger-vsock-agent` guest helper in `./build/runtime/`. + +Older ignored root artifacts such as `./runtime/`, `./banger`, and `./bangerd` +are no longer the canonical source-checkout layout. Leave them alone if you +still need them, or remove them manually after migrating to `build/`. + +If you have confirmed your current images and runtime settings no longer point +at the old checkout-local paths, a one-time cleanup looks like: +```bash +rm -rf ./runtime ./banger ./bangerd +``` Install into `~/.local/bin` by default, with the runtime bundle under `~/.local/lib/banger`: @@ -178,8 +189,9 @@ State lives under XDG directories: - runtime socket: `$XDG_RUNTIME_DIR/banger/bangerd.sock` Installed binaries resolve their runtime bundle from `../lib/banger` relative to -the executable. Source-checkout binaries resolve it from `./runtime` next to the -repo-built `./banger`. You can override either with `runtime_dir` in +the executable. Source-checkout binaries resolve it from `./build/runtime` next +to `./build/bin/banger`, and still fall back to a legacy `./runtime` checkout +bundle if that exists. You can override either with `runtime_dir` in `~/.config/banger/config.toml` or `BANGER_RUNTIME_DIR`. Useful config keys: @@ -323,32 +335,32 @@ shell helpers treated as manual workflows rather than architecture drivers. - Stopping a VM preserves its overlay and work disk. ## Rebuilding The Repo Default Rootfs -`packages.apt` controls the base apt packages baked into rebuilt images, +`config/packages.apt` controls the base apt packages baked into rebuilt images, including guest tools such as `ss` used by `banger vm ports`. -To rebuild the source-checkout default image in `./runtime/rootfs-docker.ext4`: +To rebuild the source-checkout default image in `./build/runtime/rootfs-docker.ext4`: ```bash make rootfs ``` -That rebuild also regenerates `./runtime/rootfs-docker.work-seed.ext4`, which +That rebuild also regenerates `./build/runtime/rootfs-docker.work-seed.ext4`, which the daemon uses to speed up future `vm create` calls, and bakes in the default host-reachable `opencode` server service. -If your runtime bundle does not include `./runtime/rootfs.ext4`, pass an +If your runtime bundle does not include `./build/runtime/rootfs.ext4`, pass an explicit base image instead: ```bash -./make-rootfs.sh --base-rootfs /path/to/base-rootfs.ext4 +./scripts/make-rootfs.sh --base-rootfs /path/to/base-rootfs.ext4 ``` If the package manifest changed and you want a fresh source-checkout image: ```bash -rm -f ./runtime/rootfs-docker.ext4 ./runtime/rootfs-docker.ext4.packages.sha256 +rm -f ./build/runtime/rootfs-docker.ext4 ./build/runtime/rootfs-docker.ext4.packages.sha256 make rootfs ``` -`make rootfs` expects a bootstrapped runtime bundle. If `./runtime/rootfs.ext4` -is not available, pass an explicit `--base-rootfs` to `./make-rootfs.sh`. +`make rootfs` expects a bootstrapped runtime bundle. If `./build/runtime/rootfs.ext4` +is not available, pass an explicit `--base-rootfs` to `./scripts/make-rootfs.sh`. Existing VMs keep using their current image and disks; rebuilds only affect VMs created from the rebuilt image afterward. Restarting an existing VM is not enough to pick up guest provisioning changes such as the default `opencode` @@ -363,13 +375,13 @@ make rootfs-void ``` That writes: -- `./runtime/void-kernel/` when `make void-kernel` is used -- `./runtime/rootfs-void.ext4` -- `./runtime/rootfs-void.work-seed.ext4` +- `./build/runtime/void-kernel/` when `make void-kernel` is used +- `./build/runtime/rootfs-void.ext4` +- `./build/runtime/rootfs-void.work-seed.ext4` This path is intentionally local-only and does not change the default Debian image flow. `make void-kernel` stages an actual Void `linux6.12` kernel package -under `./runtime/void-kernel/`, including the raw `vmlinuz`, extracted +under `./build/runtime/void-kernel/`, including the raw `vmlinuz`, extracted Firecracker `vmlinux`, a matching `initramfs`, the matching config, and the matching modules tree. The initramfs is generated locally with `dracut` against the downloaded Void sysroot so the kernel, initrd, and modules stay @@ -395,11 +407,11 @@ The builder fetches official static XBPS tools and packages from the Void mirror during the build. The kernel fetcher and rootfs builder currently support only `x86_64`. -The package set comes from [`packages.void`](/home/thales/projects/personal/banger/packages.void). +The package set comes from [`config/packages.void`](/home/thales/projects/personal/banger/config/packages.void). You can override the mirror, size, output path, or kernel package directly: ```bash -./make-void-kernel.sh --kernel-package linux6.12 -./make-rootfs-void.sh --mirror https://repo-default.voidlinux.org --size 2G +./scripts/make-void-kernel.sh --kernel-package linux6.12 +./scripts/make-rootfs-void.sh --mirror https://repo-default.voidlinux.org --size 2G ``` The fastest local iteration loop does not require changing your default image @@ -408,8 +420,8 @@ config at all: make void-kernel make rootfs-void make void-register -./banger vm create --image void-exp --name void-dev -./banger vm ssh void-dev +./build/bin/banger vm create --image void-exp --name void-dev +./build/bin/banger vm ssh void-dev ``` Rebuild the staged Void kernel or Void rootfs, then recreate existing @@ -425,7 +437,7 @@ make verify-void `make void-register` uses the unmanaged image registration path to create or update a `void-exp` image record in place, so repeated rebuilds do not require editing `~/.config/banger/config.toml`. It expects a complete staged Void -kernel set under `./runtime/void-kernel/` and points the experimental image at +kernel set under `./build/runtime/void-kernel/` and points the experimental image at the staged Void `vmlinux`, `initramfs`, and matching modules tree. There is also a one-step helper target: @@ -453,12 +465,12 @@ and package manifest: ```bash banger image register \ --name void-exp \ - --rootfs ./runtime/rootfs-void.ext4 \ - --work-seed ./runtime/rootfs-void.work-seed.ext4 \ - --kernel ./runtime/void-kernel/boot/vmlinux-6.12.77_1 \ - --initrd ./runtime/void-kernel/boot/initramfs-6.12.77_1.img \ - --modules ./runtime/void-kernel/lib/modules/6.12.77_1 \ - --packages ./packages.void + --rootfs ./build/runtime/rootfs-void.ext4 \ + --work-seed ./build/runtime/rootfs-void.work-seed.ext4 \ + --kernel ./build/runtime/void-kernel/boot/vmlinux-6.12.77_1 \ + --initrd ./build/runtime/void-kernel/boot/initramfs-6.12.77_1.img \ + --modules ./build/runtime/void-kernel/lib/modules/6.12.77_1 \ + --packages ./config/packages.void ``` If an unmanaged image with the same name already exists, `image register` @@ -466,17 +478,17 @@ updates it in place so future `vm create --image ` calls pick up the new artifacts immediately. ## Maintaining The Runtime Bundle -The checked-in [`runtime-bundle.toml`](/home/thales/projects/personal/banger/runtime-bundle.toml) +The checked-in [`config/runtime-bundle.toml`](/home/thales/projects/personal/banger/config/runtime-bundle.toml) is a template. Keep `bundle_metadata` accurate there, but use a separate local manifest copy when you need concrete `url` and `sha256` values for bootstrap testing or publication. -Package a local `./runtime/` tree into an archive: +Package a local `./build/runtime/` tree into an archive: ```bash make runtime-package ``` -That writes `dist/banger-runtime.tar.gz` and prints its SHA256 so you can update +That writes `build/dist/banger-runtime.tar.gz` and prints its SHA256 so you can update a local manifest copy before testing bootstrap changes or publishing the archive elsewhere. @@ -499,10 +511,10 @@ The benchmark prints JSON with: ## Remaining Shell Helpers The runtime VM lifecycle is managed through `banger`. The remaining shell scripts are not the primary user interface: -- `customize.sh`: manual reference flow for rootfs customization; `banger image build` is now Go-native, but the script still reads +- `scripts/customize.sh`: manual reference flow for rootfs customization; `banger image build` is now Go-native, but the script still reads assets from `BANGER_RUNTIME_DIR` and stores transient state under `BANGER_STATE_DIR`/XDG state -- `make-rootfs.sh`: convenience wrapper for rebuilding `./runtime/rootfs-docker.ext4` -- `interactive.sh`: manual one-off rootfs customization over SSH -- `packages.sh`: shell helper library -- `verify.sh`: smoke test for the Go workflow (`./verify.sh --nat` adds NAT coverage) +- `scripts/make-rootfs.sh`: convenience wrapper for rebuilding `./build/runtime/rootfs-docker.ext4` +- `scripts/interactive.sh`: manual one-off rootfs customization over SSH +- `scripts/lib/packages.sh`: shell helper library +- `scripts/verify.sh`: smoke test for the Go workflow (`./scripts/verify.sh --nat` adds NAT coverage) diff --git a/cmd/runtimebundle/main.go b/cmd/runtimebundle/main.go index ec72b79..28da9ea 100644 --- a/cmd/runtimebundle/main.go +++ b/cmd/runtimebundle/main.go @@ -9,6 +9,12 @@ import ( "banger/internal/runtimebundle" ) +const ( + defaultManifestPath = "config/runtime-bundle.toml" + defaultRuntimeDir = "build/runtime" + defaultArchivePath = "build/dist/banger-runtime.tar.gz" +) + func main() { if len(os.Args) < 2 { usage() @@ -34,8 +40,8 @@ func main() { func fetch(args []string) error { fs := flag.NewFlagSet("fetch", flag.ContinueOnError) fs.SetOutput(os.Stderr) - manifestPath := fs.String("manifest", "runtime-bundle.toml", "path to the runtime bundle manifest") - outDir := fs.String("out", "runtime", "destination runtime directory") + manifestPath := fs.String("manifest", defaultManifestPath, "path to the runtime bundle manifest") + outDir := fs.String("out", defaultRuntimeDir, "destination runtime directory") if err := fs.Parse(args); err != nil { return err } @@ -49,9 +55,9 @@ func fetch(args []string) error { func pkg(args []string) error { fs := flag.NewFlagSet("package", flag.ContinueOnError) fs.SetOutput(os.Stderr) - manifestPath := fs.String("manifest", "runtime-bundle.toml", "path to the runtime bundle manifest") - runtimeDir := fs.String("runtime-dir", "runtime", "runtime directory to package") - outArchive := fs.String("out", "dist/banger-runtime.tar.gz", "output archive path") + manifestPath := fs.String("manifest", defaultManifestPath, "path to the runtime bundle manifest") + runtimeDir := fs.String("runtime-dir", defaultRuntimeDir, "runtime directory to package") + outArchive := fs.String("out", defaultArchivePath, "output archive path") if err := fs.Parse(args); err != nil { return err } diff --git a/packages.apt b/config/packages.apt similarity index 100% rename from packages.apt rename to config/packages.apt diff --git a/packages.void b/config/packages.void similarity index 100% rename from packages.void rename to config/packages.void diff --git a/runtime-bundle.toml b/config/runtime-bundle.toml similarity index 92% rename from runtime-bundle.toml rename to config/runtime-bundle.toml index 7867a9d..460cb53 100644 --- a/runtime-bundle.toml +++ b/config/runtime-bundle.toml @@ -1,6 +1,6 @@ # Template manifest for local or published runtime bundle archives. # Keep this checked-in file empty by default; use a local manifest copy with -# concrete `url` and `sha256` values when bootstrapping `./runtime/`. +# concrete `url` and `sha256` values when bootstrapping `./build/runtime/`. version = "v0" url = "" sha256 = "" diff --git a/firecracker-api.yaml b/docs/reference/firecracker-api.yaml similarity index 100% rename from firecracker-api.yaml rename to docs/reference/firecracker-api.yaml diff --git a/examples/void-exp.config.toml b/examples/void-exp.config.toml index a3a1b89..192f433 100644 --- a/examples/void-exp.config.toml +++ b/examples/void-exp.config.toml @@ -5,10 +5,10 @@ # to the Void image yet; banger image build still assumes the Debian flow. # If you run `make void-kernel`, also merge the commented kernel/initrd/modules lines. -runtime_dir = "/abs/path/to/banger/runtime" +runtime_dir = "/abs/path/to/banger/build/runtime" default_image_name = "void-exp" -default_rootfs = "/abs/path/to/banger/runtime/rootfs-void.ext4" -default_work_seed = "/abs/path/to/banger/runtime/rootfs-void.work-seed.ext4" -# default_kernel = "/abs/path/to/banger/runtime/void-kernel/boot/vmlinux-6.12.77_1" -# default_initrd = "/abs/path/to/banger/runtime/void-kernel/boot/initramfs-6.12.77_1.img" -# default_modules_dir = "/abs/path/to/banger/runtime/void-kernel/lib/modules/6.12.77_1" +default_rootfs = "/abs/path/to/banger/build/runtime/rootfs-void.ext4" +default_work_seed = "/abs/path/to/banger/build/runtime/rootfs-void.work-seed.ext4" +# default_kernel = "/abs/path/to/banger/build/runtime/void-kernel/boot/vmlinux-6.12.77_1" +# default_initrd = "/abs/path/to/banger/build/runtime/void-kernel/boot/initramfs-6.12.77_1.img" +# default_modules_dir = "/abs/path/to/banger/build/runtime/void-kernel/lib/modules/6.12.77_1" diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 49c166a..b0b0f63 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -427,7 +427,7 @@ func TestAbsolutizeImageRegisterPaths(t *testing.T) { KernelPath: filepath.Join(".", "runtime", "vmlinux"), InitrdPath: filepath.Join(".", "runtime", "initrd.img"), ModulesDir: filepath.Join(".", "runtime", "modules"), - PackagesPath: filepath.Join(".", "packages.void"), + PackagesPath: filepath.Join(".", "config", "packages.void"), } wd, err := os.Getwd() diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index cf5ef8a..82ffa7f 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -111,11 +111,11 @@ func TestEnsureDefaultImageReconcilesStaleUnmanagedDefaultInPlace(t *testing.T) ID: "default-id", Name: "default", Managed: false, - RootfsPath: "/home/thales/projects/personal/banger/rootfs-docker.ext4", - KernelPath: "/home/thales/projects/personal/banger/wtf/root/boot/vmlinux-6.8.0-94-generic", - InitrdPath: "/home/thales/projects/personal/banger/wtf/root/boot/initrd.img-6.8.0-94-generic", - ModulesDir: "/home/thales/projects/personal/banger/wtf/root/lib/modules/6.8.0-94-generic", - PackagesPath: "/home/thales/projects/personal/banger/packages.apt", + RootfsPath: "/home/thales/projects/personal/banger/build/runtime/rootfs-docker.ext4", + KernelPath: "/home/thales/projects/personal/banger/build/runtime/wtf/root/boot/vmlinux-6.8.0-94-generic", + InitrdPath: "/home/thales/projects/personal/banger/build/runtime/wtf/root/boot/initrd.img-6.8.0-94-generic", + ModulesDir: "/home/thales/projects/personal/banger/build/runtime/wtf/root/lib/modules/6.8.0-94-generic", + PackagesPath: "/home/thales/projects/personal/banger/build/runtime/packages.apt", Docker: true, CreatedAt: now, UpdatedAt: now, diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 0663730..8608a19 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -86,14 +86,24 @@ func ResolveRuntimeDir(configuredRuntimeDir, deprecatedRepoRoot string) string { } exeDir := filepath.Dir(exe) if filepath.Base(exeDir) == "bin" { + if filepath.Base(filepath.Dir(exeDir)) == "build" { + buildRuntimeDir := filepath.Clean(filepath.Join(exeDir, "..", "runtime")) + if HasRuntimeBundle(buildRuntimeDir) { + return buildRuntimeDir + } + } installRuntimeDir := filepath.Clean(filepath.Join(exeDir, "..", "lib", "banger")) if HasRuntimeBundle(installRuntimeDir) { return installRuntimeDir } } - sourceRuntimeDir := filepath.Join(exeDir, "runtime") - if HasRuntimeBundle(sourceRuntimeDir) { - return sourceRuntimeDir + for _, sourceRuntimeDir := range []string{ + filepath.Join(exeDir, "build", "runtime"), + filepath.Join(exeDir, "runtime"), + } { + if HasRuntimeBundle(sourceRuntimeDir) { + return sourceRuntimeDir + } } return "" } @@ -141,7 +151,7 @@ func BangerdPath() (string, error) { return candidate, nil } } - return "", errors.New("bangerd binary not found next to banger; build ./cmd/bangerd") + return "", errors.New("bangerd binary not found next to banger; run `make build`") } func RuntimeBundleHint() string { diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go index 50cdcde..68771a9 100644 --- a/internal/paths/paths_test.go +++ b/internal/paths/paths_test.go @@ -35,9 +35,9 @@ func TestResolveRuntimeDirUsesInstalledLayout(t *testing.T) { } } -func TestResolveRuntimeDirUsesSourceCheckoutRuntimeSubdir(t *testing.T) { +func TestResolveRuntimeDirUsesBuildRuntimeForSourceCheckoutBinary(t *testing.T) { root := t.TempDir() - runtimeDir := filepath.Join(root, "runtime") + runtimeDir := filepath.Join(root, "build", "runtime") createRuntimeBundle(t, runtimeDir) origExecutablePath := executablePath @@ -53,6 +53,24 @@ func TestResolveRuntimeDirUsesSourceCheckoutRuntimeSubdir(t *testing.T) { } } +func TestResolveRuntimeDirUsesBuildRuntimeForBuildBinExecutable(t *testing.T) { + root := t.TempDir() + runtimeDir := filepath.Join(root, "build", "runtime") + createRuntimeBundle(t, runtimeDir) + + origExecutablePath := executablePath + executablePath = func() (string, error) { + return filepath.Join(root, "build", "bin", "banger"), nil + } + t.Cleanup(func() { + executablePath = origExecutablePath + }) + + if got := ResolveRuntimeDir("", ""); got != runtimeDir { + t.Fatalf("ResolveRuntimeDir() = %q, want %q", got, runtimeDir) + } +} + func createRuntimeBundle(t *testing.T, runtimeDir string) { t.Helper() metadata := runtimebundle.BundleMetadata{ diff --git a/scripts/bench-create.sh b/scripts/bench-create.sh index 59bd5e4..ff30290 100644 --- a/scripts/bench-create.sh +++ b/scripts/bench-create.sh @@ -52,7 +52,13 @@ fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -BANGER_BIN="${BANGER_BIN:-$REPO_ROOT/banger}" +if [[ -z "${BANGER_BIN:-}" ]]; then + if [[ -x "$REPO_ROOT/build/bin/banger" ]]; then + BANGER_BIN="$REPO_ROOT/build/bin/banger" + else + BANGER_BIN="$REPO_ROOT/banger" + fi +fi if [[ ! -x "$BANGER_BIN" ]]; then log "banger binary not found: $BANGER_BIN" log "run 'make build' or set BANGER_BIN" diff --git a/customize.sh b/scripts/customize.sh similarity index 96% rename from customize.sh rename to scripts/customize.sh index 1576382..52b90d6 100755 --- a/customize.sh +++ b/scripts/customize.sh @@ -7,7 +7,7 @@ log() { usage() { cat <<'EOF' -Usage: ./customize.sh [--out ] [--size ] [--kernel ] [--initrd ] [--docker] [--modules ] +Usage: ./scripts/customize.sh [--out ] [--size ] [--kernel ] [--initrd ] [--docker] [--modules ] Creates a copy of rootfs.ext4, optionally resizes it, boots a VM using the copy as a writable rootfs, then applies base configuration and packages. @@ -30,9 +30,10 @@ parse_size() { } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEFAULT_RUNTIME_DIR="$SCRIPT_DIR" -if [[ -d "$SCRIPT_DIR/runtime" ]]; then - DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEFAULT_RUNTIME_DIR="$REPO_ROOT/build/runtime" +if [[ ! -d "$DEFAULT_RUNTIME_DIR" && -d "$REPO_ROOT/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$REPO_ROOT/runtime" fi RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" if [[ ! -d "$RUNTIME_DIR" ]]; then @@ -40,7 +41,7 @@ if [[ ! -d "$RUNTIME_DIR" ]]; then log "run 'make runtime-bundle' or set BANGER_RUNTIME_DIR" exit 1 fi -source "$RUNTIME_DIR/packages.sh" +source "$SCRIPT_DIR/lib/packages.sh" STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/image-build}" VM_ROOT="$STATE/vms" mkdir -p "$VM_ROOT" @@ -83,8 +84,12 @@ resolve_banger_bin() { printf '%s\n' "$BANGER_BIN" return fi - if [[ -x "$SCRIPT_DIR/banger" ]]; then - printf '%s\n' "$SCRIPT_DIR/banger" + if [[ -x "$REPO_ROOT/build/bin/banger" ]]; then + printf '%s\n' "$REPO_ROOT/build/bin/banger" + return + fi + if [[ -x "$REPO_ROOT/banger" ]]; then + printf '%s\n' "$REPO_ROOT/banger" return fi if command -v banger >/dev/null 2>&1; then diff --git a/interactive.sh b/scripts/interactive.sh similarity index 93% rename from interactive.sh rename to scripts/interactive.sh index b89114b..bda0798 100755 --- a/interactive.sh +++ b/scripts/interactive.sh @@ -7,7 +7,7 @@ log() { usage() { cat <<'EOF' -Usage: ./interactive.sh [--out ] [--size ] +Usage: ./scripts/interactive.sh [--out ] [--size ] Creates a writable copy of the base rootfs and boots a VM so you can customize it manually over SSH. No automatic package/config changes @@ -30,10 +30,11 @@ parse_size() { return 1 } -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEFAULT_RUNTIME_DIR="$DIR" -if [[ -d "$DIR/runtime" ]]; then - DEFAULT_RUNTIME_DIR="$DIR/runtime" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEFAULT_RUNTIME_DIR="$REPO_ROOT/build/runtime" +if [[ ! -d "$DEFAULT_RUNTIME_DIR" && -d "$REPO_ROOT/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$REPO_ROOT/runtime" fi RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" if [[ ! -d "$RUNTIME_DIR" ]]; then @@ -77,8 +78,12 @@ resolve_banger_bin() { printf '%s\n' "$BANGER_BIN" return fi - if [[ -x "$DIR/banger" ]]; then - printf '%s\n' "$DIR/banger" + if [[ -x "$REPO_ROOT/build/bin/banger" ]]; then + printf '%s\n' "$REPO_ROOT/build/bin/banger" + return + fi + if [[ -x "$REPO_ROOT/banger" ]]; then + printf '%s\n' "$REPO_ROOT/banger" return fi if command -v banger >/dev/null 2>&1; then diff --git a/packages.sh b/scripts/lib/packages.sh similarity index 92% rename from packages.sh rename to scripts/lib/packages.sh index 25af4c7..3a87295 100644 --- a/packages.sh +++ b/scripts/lib/packages.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -BANGER_PACKAGES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BANGER_APT_PACKAGES_FILE="${BANGER_APT_PACKAGES_FILE:-$BANGER_PACKAGES_DIR/packages.apt}" +readonly BANGER_PACKAGES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly BANGER_REPO_ROOT="$(cd "$BANGER_PACKAGES_DIR/../.." && pwd)" +BANGER_APT_PACKAGES_FILE="${BANGER_APT_PACKAGES_FILE:-$BANGER_REPO_ROOT/config/packages.apt}" banger_packages_file() { printf '%s' "$BANGER_APT_PACKAGES_FILE" diff --git a/make-rootfs-void.sh b/scripts/make-rootfs-void.sh similarity index 93% rename from make-rootfs-void.sh rename to scripts/make-rootfs-void.sh index 1cf5c89..918be10 100755 --- a/make-rootfs-void.sh +++ b/scripts/make-rootfs-void.sh @@ -7,21 +7,21 @@ log() { usage() { cat <<'EOF' -Usage: ./make-rootfs-void.sh [--out ] [--size ] [--mirror ] [--arch ] [--packages ] +Usage: ./scripts/make-rootfs-void.sh [--out ] [--size ] [--mirror ] [--arch ] [--packages ] Build an experimental Void Linux rootfs image plus a matching /root work-seed. Defaults: - --out ./runtime/rootfs-void.ext4 + --out ./build/runtime/rootfs-void.ext4 --size 2G --mirror https://repo-default.voidlinux.org --arch x86_64 - --packages ./packages.void + --packages ./config/packages.void -This path is experimental and local-only. If ./runtime/void-kernel exists it -uses the staged Void kernel modules from that directory; otherwise it falls back -to the current runtime bundle modules. It does not change the default Debian -image flow. +This path is experimental and local-only. If ./build/runtime/void-kernel exists +it uses the staged Void kernel modules from that directory; otherwise it falls +back to the current runtime bundle modules. It does not change the default +Debian image flow. EOF } @@ -53,8 +53,12 @@ resolve_banger_bin() { printf '%s\n' "$BANGER_BIN" return fi - if [[ -x "$SCRIPT_DIR/banger" ]]; then - printf '%s\n' "$SCRIPT_DIR/banger" + if [[ -x "$REPO_ROOT/build/bin/banger" ]]; then + printf '%s\n' "$REPO_ROOT/build/bin/banger" + return + fi + if [[ -x "$REPO_ROOT/banger" ]]; then + printf '%s\n' "$REPO_ROOT/banger" return fi if command -v banger >/dev/null 2>&1; then @@ -377,13 +381,14 @@ cleanup() { } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PACKAGES_FILE="$SCRIPT_DIR/packages.void" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PACKAGES_FILE="$REPO_ROOT/config/packages.void" export BANGER_APT_PACKAGES_FILE="$PACKAGES_FILE" -source "$SCRIPT_DIR/packages.sh" +source "$SCRIPT_DIR/lib/packages.sh" -DEFAULT_RUNTIME_DIR="$SCRIPT_DIR" -if [[ -d "$SCRIPT_DIR/runtime" ]]; then - DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime" +DEFAULT_RUNTIME_DIR="$REPO_ROOT/build/runtime" +if [[ ! -d "$DEFAULT_RUNTIME_DIR" && -d "$REPO_ROOT/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$REPO_ROOT/runtime" fi RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" if [[ ! -d "$RUNTIME_DIR" ]]; then @@ -401,8 +406,8 @@ ARCH="x86_64" MISE_VERSION="v2025.12.0" MISE_INSTALL_PATH="/usr/local/bin/mise" OPENCODE_TOOL="github:anomalyco/opencode" -GUESTNET_BOOTSTRAP_SCRIPT="$SCRIPT_DIR/internal/guestnet/assets/bootstrap.sh" -GUESTNET_VOID_CORE_SERVICE="$SCRIPT_DIR/internal/guestnet/assets/void-core-service.sh" +GUESTNET_BOOTSTRAP_SCRIPT="$REPO_ROOT/internal/guestnet/assets/bootstrap.sh" +GUESTNET_VOID_CORE_SERVICE="$REPO_ROOT/internal/guestnet/assets/void-core-service.sh" MODULES_DIR="$(bundle_path default_modules_dir "$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic")" VOID_KERNEL_MODULES_DIR="$(find_latest_module_dir "$RUNTIME_DIR/void-kernel/lib/modules" || true)" VSOCK_AGENT="$(bundle_path vsock_agent_path "$RUNTIME_DIR/banger-vsock-agent")" diff --git a/make-rootfs.sh b/scripts/make-rootfs.sh similarity index 57% rename from make-rootfs.sh rename to scripts/make-rootfs.sh index dd83d55..4fd20b6 100755 --- a/make-rootfs.sh +++ b/scripts/make-rootfs.sh @@ -7,19 +7,25 @@ log() { usage() { cat <<'EOF' -Usage: ./make-rootfs.sh [--size ] [--base-rootfs ] +Usage: ./scripts/make-rootfs.sh [--size ] [--base-rootfs ] -Builds rootfs-docker.ext4 using customize.sh. If --base-rootfs is omitted, -the first existing file is used: - ./rootfs.ext4 +Builds build/runtime/rootfs-docker.ext4 using scripts/customize.sh. If +--base-rootfs is omitted, the first existing file is used: + ./build/runtime/rootfs.ext4 + ./runtime/rootfs.ext4 (legacy fallback) ./ubuntu-noble-rootfs/rootfs.ext4 ./ubuntu-lts/rootfs.ext4 EOF } -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$DIR/packages.sh" -RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DIR/runtime}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$SCRIPT_DIR/lib/packages.sh" +DEFAULT_RUNTIME_DIR="$REPO_ROOT/build/runtime" +if [[ ! -d "$DEFAULT_RUNTIME_DIR" && -d "$REPO_ROOT/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$REPO_ROOT/runtime" +fi +RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" if [[ ! -d "$RUNTIME_DIR" ]]; then log "runtime bundle not found: $RUNTIME_DIR" log "run 'make runtime-bundle' or set BANGER_RUNTIME_DIR" @@ -63,10 +69,10 @@ fi if [[ -z "$BASE_ROOTFS" ]]; then if [[ -f "$RUNTIME_DIR/rootfs.ext4" ]]; then BASE_ROOTFS="$RUNTIME_DIR/rootfs.ext4" - elif [[ -f "$DIR/ubuntu-noble-rootfs/rootfs.ext4" ]]; then - BASE_ROOTFS="$DIR/ubuntu-noble-rootfs/rootfs.ext4" - elif [[ -f "$DIR/ubuntu-lts/rootfs.ext4" ]]; then - BASE_ROOTFS="$DIR/ubuntu-lts/rootfs.ext4" + elif [[ -f "$REPO_ROOT/ubuntu-noble-rootfs/rootfs.ext4" ]]; then + BASE_ROOTFS="$REPO_ROOT/ubuntu-noble-rootfs/rootfs.ext4" + elif [[ -f "$REPO_ROOT/ubuntu-lts/rootfs.ext4" ]]; then + BASE_ROOTFS="$REPO_ROOT/ubuntu-lts/rootfs.ext4" else log "no base rootfs found; run 'make runtime-bundle' or pass --base-rootfs" exit 1 @@ -76,7 +82,7 @@ fi mkdir -p "$RUNTIME_DIR" log "building $OUT_ROOTFS from $BASE_ROOTFS" -exec env BANGER_RUNTIME_DIR="$RUNTIME_DIR" "$DIR/customize.sh" "$BASE_ROOTFS" \ +exec env BANGER_RUNTIME_DIR="$RUNTIME_DIR" "$SCRIPT_DIR/customize.sh" "$BASE_ROOTFS" \ --out "$OUT_ROOTFS" \ --size "$SIZE_SPEC" \ --docker diff --git a/make-void-kernel.sh b/scripts/make-void-kernel.sh similarity index 95% rename from make-void-kernel.sh rename to scripts/make-void-kernel.sh index fb67ba4..13f92f8 100755 --- a/make-void-kernel.sh +++ b/scripts/make-void-kernel.sh @@ -7,13 +7,14 @@ log() { usage() { cat <<'EOF' -Usage: ./make-void-kernel.sh [--out-dir ] [--mirror ] [--arch ] [--kernel-package ] [--print-register-flags] +Usage: ./scripts/make-void-kernel.sh [--out-dir ] [--mirror ] [--arch ] [--kernel-package ] [--print-register-flags] -Download and stage a Void Linux kernel under ./runtime/void-kernel for the +Download and stage a Void Linux kernel under ./build/runtime/void-kernel for +the experimental Void guest flow. Defaults: - --out-dir ./runtime/void-kernel + --out-dir ./build/runtime/void-kernel --mirror https://repo-default.voidlinux.org --arch x86_64 --kernel-package linux6.12 @@ -223,9 +224,10 @@ cleanup() { } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEFAULT_RUNTIME_DIR="$SCRIPT_DIR" -if [[ -d "$SCRIPT_DIR/runtime" ]]; then - DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEFAULT_RUNTIME_DIR="$REPO_ROOT/build/runtime" +if [[ ! -d "$DEFAULT_RUNTIME_DIR" && -d "$REPO_ROOT/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$REPO_ROOT/runtime" fi RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" OUT_DIR="$RUNTIME_DIR/void-kernel" @@ -282,10 +284,7 @@ if [[ "$ARCH" != "x86_64" ]]; then log "this experimental downloader currently supports only x86_64" exit 1 fi -if [[ ! -d "$RUNTIME_DIR" ]]; then - log "runtime bundle not found: $RUNTIME_DIR" - exit 1 -fi +mkdir -p "$RUNTIME_DIR" if [[ -e "$OUT_DIR" ]]; then log "output directory already exists: $OUT_DIR" log "remove it first if you want to re-stage a different Void kernel" diff --git a/register-void-image.sh b/scripts/register-void-image.sh similarity index 82% rename from register-void-image.sh rename to scripts/register-void-image.sh index 1d3a343..bc4ccfc 100755 --- a/register-void-image.sh +++ b/scripts/register-void-image.sh @@ -27,8 +27,12 @@ resolve_banger_bin() { printf '%s\n' "$BANGER_BIN" return fi - if [[ -x "$SCRIPT_DIR/banger" ]]; then - printf '%s\n' "$SCRIPT_DIR/banger" + if [[ -x "$REPO_ROOT/build/bin/banger" ]]; then + printf '%s\n' "$REPO_ROOT/build/bin/banger" + return + fi + if [[ -x "$REPO_ROOT/banger" ]]; then + printf '%s\n' "$REPO_ROOT/banger" return fi if command -v banger >/dev/null 2>&1; then @@ -40,9 +44,10 @@ resolve_banger_bin() { } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEFAULT_RUNTIME_DIR="$SCRIPT_DIR" -if [[ -d "$SCRIPT_DIR/runtime" ]]; then - DEFAULT_RUNTIME_DIR="$SCRIPT_DIR/runtime" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEFAULT_RUNTIME_DIR="$REPO_ROOT/build/runtime" +if [[ ! -d "$DEFAULT_RUNTIME_DIR" && -d "$REPO_ROOT/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$REPO_ROOT/runtime" fi RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" @@ -50,7 +55,7 @@ IMAGE_NAME="${VOID_IMAGE_NAME:-void-exp}" BANGER_BIN="$(resolve_banger_bin)" ROOTFS="$RUNTIME_DIR/rootfs-void.ext4" WORK_SEED="$RUNTIME_DIR/rootfs-void.work-seed.ext4" -PACKAGES="$SCRIPT_DIR/packages.void" +PACKAGES="$REPO_ROOT/config/packages.void" if [[ ! -f "$ROOTFS" ]]; then log "missing Void rootfs: $ROOTFS" diff --git a/verify.sh b/scripts/verify.sh similarity index 85% rename from verify.sh rename to scripts/verify.sh index 53478e1..3b963f7 100755 --- a/verify.sh +++ b/scripts/verify.sh @@ -5,10 +5,11 @@ log() { printf '[verify] %s\n' "$*" } -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEFAULT_RUNTIME_DIR="$DIR" -if [[ -d "$DIR/runtime" ]]; then - DEFAULT_RUNTIME_DIR="$DIR/runtime" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEFAULT_RUNTIME_DIR="$REPO_ROOT/build/runtime" +if [[ ! -d "$DEFAULT_RUNTIME_DIR" && -d "$REPO_ROOT/runtime" ]]; then + DEFAULT_RUNTIME_DIR="$REPO_ROOT/runtime" fi RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" SSH_KEY="$RUNTIME_DIR/id_ed25519" @@ -35,6 +36,29 @@ SSH_COMMON_ARGS=( ) OPENCODE_PORT=4096 +resolve_banger_bin() { + if [[ -n "${BANGER_BIN:-}" ]]; then + printf '%s\n' "$BANGER_BIN" + return + fi + if [[ -x "$REPO_ROOT/build/bin/banger" ]]; then + printf '%s\n' "$REPO_ROOT/build/bin/banger" + return + fi + if [[ -x "$REPO_ROOT/banger" ]]; then + printf '%s\n' "$REPO_ROOT/banger" + return + fi + if command -v banger >/dev/null 2>&1; then + command -v banger + return + fi + log "banger binary not found; run 'make build' or set BANGER_BIN" + exit 1 +} + +BANGER_BIN="$(resolve_banger_bin)" + firecracker_running() { local pid="$1" local api_sock="$2" @@ -85,7 +109,7 @@ wait_for_tcp() { } refresh_vm_metadata() { - if ! VM_JSON="$(./banger vm show "$VM_NAME" 2>/dev/null)"; then + if ! VM_JSON="$("$BANGER_BIN" vm show "$VM_NAME" 2>/dev/null)"; then return 1 fi TAP="$(printf '%s\n' "$VM_JSON" | jq -r '.runtime.tap_device // empty')" @@ -125,13 +149,13 @@ wait_for_vm_ready() { dump_diagnostics() { log "diagnostics for $VM_NAME" - ./banger vm show "$VM_NAME" || true + "$BANGER_BIN" vm show "$VM_NAME" || true if [[ "${PID:-0}" -gt 0 ]]; then log "process state for pid $PID" ps -fp "$PID" || true fi log "recent firecracker log" - ./banger vm logs "$VM_NAME" 2>/dev/null | tail -n 200 || true + "$BANGER_BIN" vm logs "$VM_NAME" 2>/dev/null | tail -n 200 || true if [[ -f "$DAEMON_LOG" ]]; then log "recent daemon log" tail -n 200 "$DAEMON_LOG" || true @@ -153,7 +177,7 @@ dump_diagnostics() { usage() { cat <<'EOF' -Usage: ./verify.sh [--nat] [--image ] +Usage: ./scripts/verify.sh [--nat] [--image ] Run a basic smoke test for the Go VM workflow. Use --nat to additionally verify outbound NAT and host rule cleanup. @@ -198,20 +222,20 @@ LAST_ERROR="" delete_vm() { if [[ -n "${VM_NAME:-}" ]]; then - ./banger vm delete "$VM_NAME" + "$BANGER_BIN" vm delete "$VM_NAME" fi } cleanup() { if [[ -n "${VM_NAME:-}" ]]; then - ./banger vm delete "$VM_NAME" >/dev/null 2>&1 || true + "$BANGER_BIN" vm delete "$VM_NAME" >/dev/null 2>&1 || true fi } trap cleanup EXIT log "starting VM" -CREATE_ARGS=(./banger vm create --name "$VM_NAME") +CREATE_ARGS=("$BANGER_BIN" vm create --name "$VM_NAME") if [[ -n "$IMAGE_NAME" ]]; then CREATE_ARGS+=(--image "$IMAGE_NAME") fi @@ -267,7 +291,7 @@ if ! wait_for_tcp "$GUEST_IP" "$OPENCODE_PORT" "$BOOT_DEADLINE"; then fi log "asserting opencode port is reported by banger vm ports" -if ! ./banger vm ports "$VM_NAME" | grep -F ":${OPENCODE_PORT}" >/dev/null 2>&1; then +if ! "$BANGER_BIN" vm ports "$VM_NAME" | grep -F ":${OPENCODE_PORT}" >/dev/null 2>&1; then log "banger vm ports did not report ${OPENCODE_PORT}" dump_diagnostics exit 1 @@ -286,7 +310,7 @@ if ! delete_vm; then fi log "asserting cleanup success" -if ./banger vm show "$VM_NAME" >/dev/null 2>&1; then +if "$BANGER_BIN" vm show "$VM_NAME" >/dev/null 2>&1; then log "vm still exists after delete: $VM_NAME" exit 1 fi From 572bf32424dc3f016ef42127aaec40775ff42e53 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 21 Mar 2026 18:34:53 -0300 Subject: [PATCH 006/257] Remove runtime-bundle image dependencies Hard-cut banger away from source-checkout runtime bundles as an implicit source of\nimage and host defaults. Managed images now own their full boot set,\nimage build starts from an existing registered image, and daemon startup\nno longer synthesizes a default image from host paths.\n\nResolve Firecracker from PATH or firecracker_bin, make SSH keys config-owned\nwith an auto-managed XDG default, replace the external name generator and\npackage manifests with Go code, and keep the vsock helper as a companion\nbinary instead of a user-managed runtime asset.\n\nUpdate the manual scripts, web/CLI forms, config surface, and docs around\nthe new build/manual flow and explicit image registration semantics.\n\nValidation: GOCACHE=/tmp/banger-gocache go test ./..., bash -n scripts/*.sh,\nand make build. --- AGENTS.md | 86 ++- Makefile | 77 +-- README.md | 558 ++++--------------- cmd/runtimebundle/main.go | 78 --- config/packages.apt | 10 - config/packages.void | 27 - config/runtime-bundle.toml | 33 -- examples/void-exp.config.toml | 15 +- internal/api/types.go | 3 +- internal/cli/banger.go | 110 +++- internal/cli/cli_test.go | 8 +- internal/config/config.go | 251 +++------ internal/config/config_test.go | 360 +++---------- internal/daemon/capabilities.go | 12 +- internal/daemon/daemon.go | 98 +--- internal/daemon/daemon_test.go | 746 +++----------------------- internal/daemon/doctor.go | 86 +-- internal/daemon/imagebuild.go | 25 +- internal/daemon/images.go | 178 +++--- internal/daemon/logger_test.go | 30 +- internal/daemon/preflight.go | 43 +- internal/daemon/runtime_assets.go | 15 + internal/daemon/vm.go | 28 +- internal/daemon/vm_test.go | 94 +++- internal/imagepreset/preset.go | 57 ++ internal/model/types.go | 14 +- internal/namegen/namegen.go | 71 +++ internal/paths/paths.go | 101 +--- internal/paths/paths_test.go | 103 ++-- internal/runtimebundle/bundle.go | 497 ----------------- internal/runtimebundle/bundle_test.go | 288 ---------- internal/store/store.go | 13 +- internal/store/store_test.go | 1 - internal/system/system.go | 4 + internal/webui/server.go | 27 +- internal/webui/templates/images.html | 18 +- scripts/customize.sh | 82 ++- scripts/interactive.sh | 52 +- scripts/lib/packages.sh | 116 ---- scripts/make-rootfs-void.sh | 97 ++-- scripts/make-rootfs.sh | 75 +-- scripts/make-void-kernel.sh | 12 +- scripts/register-void-image.sh | 9 +- scripts/verify.sh | 42 +- 44 files changed, 1194 insertions(+), 3456 deletions(-) delete mode 100644 cmd/runtimebundle/main.go delete mode 100644 config/packages.apt delete mode 100644 config/packages.void delete mode 100644 config/runtime-bundle.toml create mode 100644 internal/daemon/runtime_assets.go create mode 100644 internal/imagepreset/preset.go create mode 100644 internal/namegen/namegen.go delete mode 100644 internal/runtimebundle/bundle.go delete mode 100644 internal/runtimebundle/bundle_test.go delete mode 100644 scripts/lib/packages.sh diff --git a/AGENTS.md b/AGENTS.md index 5cfea46..60d086d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,55 +1,49 @@ # Repository Guidelines -## Project Structure & Module Organization -- `cmd/banger` and `cmd/bangerd` are the primary user-facing entrypoints. -- `internal/` contains the daemon, CLI, RPC, storage, Firecracker, and system integration code. -- The VM lifecycle is now organized around daemon capabilities plus a structured guest-config builder. New host-integrated VM features should plug into that Go path instead of adding more one-off branches through `internal/daemon/vm.go`. -- `scripts/customize.sh`, `scripts/make-rootfs.sh`, and `scripts/interactive.sh` remain as manual rootfs/customization helpers; normal VM lifecycle, NAT, `.vm` DNS, and daemon-driven image builds are handled by the Go control plane. -- Source checkouts use a generated `./build/runtime/` bundle for Firecracker, kernels, modules, rootfs images, and helper copies. Bundle defaults come from `./build/runtime/bundle.json` when present. Those runtime artifacts are not meant to be tracked directly in Git. -- The daemon keeps state under XDG directories rather than the old repo-local `state/` layout. +## Project Structure -## Build, Test, and Development Commands -- `make build` builds `./build/bin/banger`, `./build/bin/bangerd`, and the bundled `./build/runtime/banger-vsock-agent` guest helper. -- `make bench-create` benchmarks `vm create` and first-SSH readiness on the current host. -- `make runtime-bundle` bootstraps `./build/runtime/` from the archive referenced by `RUNTIME_MANIFEST`; the checked-in `config/runtime-bundle.toml` is only a template. -- `make void-kernel` downloads and stages a Void `linux6.12` kernel under `./build/runtime/void-kernel`, including extracted `vmlinux`, raw `vmlinuz`, a matching generated `initramfs`, config, and matching modules. -- `make rootfs-void` builds an experimental local-only `x86_64-glibc` Void rootfs plus work-seed under `./build/runtime/`; it prefers staged `./build/runtime/void-kernel` modules when present, but does not replace the default Debian path or teach `banger image build` about Void. -- `make verify-void` registers `void-exp` and runs the normal smoke test against that image. -- `banger` validates required host tools per command and reports actionable missing-tool errors; do not assume one workstation's package set. -- `./build/bin/banger vm create --name testbox` creates and starts a VM. -- `./build/bin/banger vm create` now blocks until the guest reaches the daemon's default readiness checks and shows live progress stages on TTY stderr while it waits. -- `./build/bin/banger vm ssh testbox` connects to a running guest using the runtime bundle SSH key and reminds the user if the VM is still running when the session exits. -- `./build/bin/banger vm stop testbox` stops a VM while preserving its disks. -- `./build/bin/banger vm stop vm-a vm-b vm-c` and `./build/bin/banger vm set --nat web-1 web-2` are supported; multi-VM lifecycle and `set` actions fan out concurrently through the CLI. -- `./build/bin/banger doctor` reports runtime bundle, host tool, feature, and image-build readiness from the same Go checks used by the daemon. -- `./build/bin/banger image register --name local --rootfs /abs/path/rootfs.ext4` creates or updates an unmanaged image record without changing the default image config; use it for experimental guest iteration paths such as Void. -- `bangerd` now also serves a localhost web UI on `http://127.0.0.1:7777` by default unless `web_listen_addr = ""` disables it; the UI uses server-rendered templates, polls async VM/image operations, and keeps image path selection on the host via a server-side file picker. +- `cmd/banger` and `cmd/bangerd` are the main user entrypoints. +- `internal/` contains the daemon, CLI, RPC, storage, Firecracker integration, guest helpers, and web UI. +- `scripts/` contains explicit manual helper workflows for rootfs and kernel preparation. +- `build/bin/` is the canonical source-checkout build output. +- `build/manual/` is the canonical source-checkout location for manual rootfs/kernel artifacts. + +## Build and Test + +- `make build` builds `./build/bin/banger`, `./build/bin/bangerd`, and `./build/bin/banger-vsock-agent`. - `make test` runs `go test ./...`. -- `./scripts/verify.sh` runs the smoke test for the Go VM workflow. +- `./build/bin/banger doctor` checks host readiness. +- `./build/bin/banger image build --from-image ` builds a managed image from an existing registered image. +- `./build/bin/banger image register ...` registers an unmanaged host-side image stack. +- `./build/bin/banger image promote ` copies an unmanaged image into daemon-owned managed artifacts. +- `make void-kernel`, `make rootfs-void`, and `make void-register` drive the experimental Void flow under `./build/manual`. -## Coding Style & Naming Conventions -- Go code should stay small, direct, and standard-library-first unless there is a clear reason otherwise. -- Shell helpers use Bash with `set -euo pipefail`; keep remaining shell scripts strict and explicit. -- Prefer lowercase filenames with short descriptive names. -- Use `gofmt` for Go formatting; no extra formatter is configured for shell files. +## Image Model + +- Managed images own the full boot set: rootfs, optional work-seed, kernel, optional initrd, and optional modules. +- There is no runtime bundle and no auto-registered default image from disk paths. +- `default_image_name` selects a registered image only. + +## Config + +- Config lives at `~/.config/banger/config.toml`. +- Firecracker comes from `PATH` by default, or `firecracker_bin`. +- SSH uses `ssh_key_path` or an auto-managed default key at `~/.config/banger/ssh/id_ed25519`. + +## Coding Style + +- Prefer small, direct Go code and standard library solutions. +- Keep shell scripts strict with `set -euo pipefail`. +- Use `gofmt` for Go formatting. + +## Testing Guidance -## Testing Guidelines - Primary automated coverage is `go test ./...`. -- Manual verification for VM lifecycle changes: `./build/bin/banger vm create`, confirm SSH access, then stop/delete the VM. -- For host-integration changes, run `./build/bin/banger doctor` as a quick readiness check before the live VM smoke. -- The web UI follows the same sudo model as the CLI path: bangerd stays unprivileged and privileged writes only work when `sudo -v` is already warm or sudo is passwordless. -- Rebuilt images now include `mise`, `opencode`, a host-reachable default `opencode` server service on guest TCP port `4096`, `tmux-resurrect`/`tmux-continuum` defaults for `root`, and the `banger-vsock-agent` service used by the SSH reminder and guest health-check path; if you change guest provisioning, document whether users need to rebuild `./build/runtime/rootfs-docker.ext4` or another base image to pick it up. -- The experimental Void rootfs path now includes the repo's basic dev baseline plus Docker and Compose, alongside boot, SSH, a guest network bootstrap sourced from the kernel `ip=` cmdline, the vsock HTTP health agent, pinned `mise` plus `opencode` for `root`, the default host-reachable `opencode` server service on guest TCP port `4096`, a `bash` root shell while leaving `/bin/sh` alone, and the `/root` work-seed. When `./build/runtime/void-kernel/` exists, the Void image registration path expects a complete staged Void kernel, initramfs, and modules tree and points `void-exp` at it. Keep further baked-in tooling deliberate and user-driven. -- Rebuilt images also emit a `work-seed.ext4` sidecar used to speed up future VM creates. Older managed images may take one slower create to refresh seeded SSH access before they rejoin the fast path. If you touch `/root` provisioning, verify both the rootfs and the work-seed output. -- The daemon may keep idle TAP devices in a pool for faster creates. Smoke tests should treat `tap-pool-*` devices as reusable capacity, not cleanup leaks. -- If you add a new operational workflow, document how to exercise it in `README.md`. -- For NAT changes, verify both guest outbound access and host rule cleanup, for example with `./scripts/verify.sh --nat`. +- For lifecycle changes, smoke-test with `vm create`, `vm ssh`, `vm stop`, and `vm delete`. +- If guest provisioning changes, document whether existing images must be rebuilt or recreated. -## Commit & Pull Request Guidelines -- Git history uses short, imperative subjects. -- Prefer a real commit body when the change affects lifecycle behavior, storage semantics, or host integration. -- PRs should call out runtime requirements, migration impact, and any host-side verification performed. +## Security -## Security & Configuration Tips -- The VM workflow requires `sudo` and `/dev/kvm` access; do not commit secrets. -- `id_ed25519` lives inside the runtime bundle; rotate or replace it before publishing a shared bundle. +- Do not commit secrets. +- VM workflows require `sudo` and `/dev/kvm`. +- The default SSH key is local configuration, not a checked-in runtime artifact. diff --git a/Makefile b/Makefile index 50a17fa..1b4e37e 100644 --- a/Makefile +++ b/Makefile @@ -6,50 +6,40 @@ INSTALL ?= install PREFIX ?= $(HOME)/.local BINDIR ?= $(PREFIX)/bin LIBDIR ?= $(PREFIX)/lib -RUNTIMEDIR ?= $(LIBDIR)/banger DESTDIR ?= BUILD_DIR ?= build BUILD_BIN_DIR ?= $(BUILD_DIR)/bin -RUNTIME_MANIFEST ?= config/runtime-bundle.toml -RUNTIME_SOURCE_DIR ?= $(BUILD_DIR)/runtime -RUNTIME_ARCHIVE ?= $(BUILD_DIR)/dist/banger-runtime.tar.gz +BUILD_MANUAL_DIR ?= $(BUILD_DIR)/manual BANGER_BIN ?= $(BUILD_BIN_DIR)/banger BANGERD_BIN ?= $(BUILD_BIN_DIR)/bangerd -BINARIES := $(BANGER_BIN) $(BANGERD_BIN) -RUNTIME_HELPERS := $(RUNTIME_SOURCE_DIR)/banger-vsock-agent +VSOCK_AGENT_BIN ?= $(BUILD_BIN_DIR)/banger-vsock-agent +BINARIES := $(BANGER_BIN) $(BANGERD_BIN) $(VSOCK_AGENT_BIN) GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort) -RUNTIME_EXECUTABLES := firecracker customize.sh packages.sh namegen banger-vsock-agent -RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4 -RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 rootfs-docker.work-seed.ext4 bundle.json -RUNTIME_BOOT_FILES := wtf/root/boot/vmlinux-6.8.0-94-generic wtf/root/boot/initrd.img-6.8.0-94-generic -RUNTIME_MODULES_DIR := wtf/root/lib/modules/6.8.0-94-generic VOID_IMAGE_NAME ?= void-exp VOID_VM_NAME ?= void-dev .DEFAULT_GOAL := help -.PHONY: help build banger bangerd test fmt tidy clean rootfs rootfs-void void-kernel void-register void-vm verify-void install runtime-bundle runtime-package check-runtime bench-create +.PHONY: help build banger bangerd test fmt tidy clean rootfs rootfs-void void-kernel void-register void-vm verify-void install bench-create help: @printf '%s\n' \ 'Targets:' \ - ' make build Build ./build/bin/banger and ./build/bin/bangerd' \ - ' make runtime-bundle Fetch and unpack ./build/runtime from the archive referenced by $(RUNTIME_MANIFEST)' \ - ' make runtime-package Package $(RUNTIME_SOURCE_DIR) into $(RUNTIME_ARCHIVE) and print its SHA256' \ + ' make build Build ./build/bin/banger, ./build/bin/bangerd, and ./build/bin/banger-vsock-agent' \ ' make bench-create Benchmark vm create and SSH readiness with scripts/bench-create.sh' \ - ' make install Build and install binaries plus the runtime bundle into $(DESTDIR)$(BINDIR) and $(DESTDIR)$(RUNTIMEDIR)' \ + ' make install Build and install banger, bangerd, and the companion vsock helper' \ ' 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 Rebuild the source-checkout default Debian rootfs image in ./build/runtime' \ - ' make void-kernel Download and stage a Void kernel, initramfs, and modules under ./build/runtime/void-kernel' \ - ' make rootfs-void Build an experimental Void Linux rootfs and work-seed in ./build/runtime' \ + ' make rootfs Rebuild the manual Debian rootfs image in ./build/manual' \ + ' make void-kernel Download and stage a Void kernel, initramfs, and modules under ./build/manual/void-kernel' \ + ' make rootfs-void Build an experimental Void Linux rootfs and work-seed in ./build/manual' \ ' make void-register Register or update the experimental Void image as $(VOID_IMAGE_NAME)' \ ' make void-vm Register the experimental Void image and create a VM named $(VOID_VM_NAME)' \ ' make verify-void Register the experimental Void image and run scripts/verify.sh against it' -build: $(BINARIES) $(RUNTIME_HELPERS) +build: $(BINARIES) $(BANGER_BIN): $(GO_SOURCES) go.mod go.sum mkdir -p "$(BUILD_BIN_DIR)" @@ -59,9 +49,9 @@ $(BANGERD_BIN): $(GO_SOURCES) go.mod go.sum mkdir -p "$(BUILD_BIN_DIR)" $(GO) build -o "$(BANGERD_BIN)" ./cmd/bangerd -$(RUNTIME_SOURCE_DIR)/banger-vsock-agent: $(GO_SOURCES) go.mod go.sum - mkdir -p "$(RUNTIME_SOURCE_DIR)" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -o "$(RUNTIME_SOURCE_DIR)/banger-vsock-agent" ./cmd/banger-vsock-agent +$(VSOCK_AGENT_BIN): $(GO_SOURCES) go.mod go.sum + mkdir -p "$(BUILD_BIN_DIR)" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -o "$(VSOCK_AGENT_BIN)" ./cmd/banger-vsock-agent test: $(GO) test ./... @@ -74,55 +64,28 @@ tidy: clean: rm -rf "$(BUILD_BIN_DIR)" - rm -f "$(RUNTIME_SOURCE_DIR)/banger-vsock-agent" - -runtime-bundle: - $(GO) run ./cmd/runtimebundle fetch --manifest "$(RUNTIME_MANIFEST)" --out "$(RUNTIME_SOURCE_DIR)" - -runtime-package: - $(GO) run ./cmd/runtimebundle package --manifest "$(RUNTIME_MANIFEST)" --runtime-dir "$(RUNTIME_SOURCE_DIR)" --out "$(RUNTIME_ARCHIVE)" bench-create: build BANGER_BIN="$(abspath $(BANGER_BIN))" bash ./scripts/bench-create.sh $(ARGS) -check-runtime: - @test -d "$(RUNTIME_SOURCE_DIR)" || { echo "missing runtime bundle directory: $(RUNTIME_SOURCE_DIR); run 'make runtime-bundle'" >&2; exit 1; } - @for path in $(RUNTIME_EXECUTABLES) $(RUNTIME_DATA_FILES) $(RUNTIME_BOOT_FILES) $(RUNTIME_MODULES_DIR); do \ - test -e "$(RUNTIME_SOURCE_DIR)/$$path" || { echo "missing runtime artifact: $(RUNTIME_SOURCE_DIR)/$$path; run 'make runtime-bundle'" >&2; exit 1; }; \ - done - -install: build check-runtime +install: build mkdir -p "$(DESTDIR)$(BINDIR)" - mkdir -p "$(DESTDIR)$(RUNTIMEDIR)" - mkdir -p "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/boot" - mkdir -p "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/lib/modules" + mkdir -p "$(DESTDIR)$(LIBDIR)/banger" $(INSTALL) -m 0755 "$(BANGER_BIN)" "$(DESTDIR)$(BINDIR)/banger" $(INSTALL) -m 0755 "$(BANGERD_BIN)" "$(DESTDIR)$(BINDIR)/bangerd" - @for path in $(RUNTIME_EXECUTABLES); do \ - $(INSTALL) -m 0755 "$(RUNTIME_SOURCE_DIR)/$$path" "$(DESTDIR)$(RUNTIMEDIR)/$$path"; \ - done - @for path in $(RUNTIME_DATA_FILES) $(RUNTIME_BOOT_FILES); do \ - $(INSTALL) -m 0644 "$(RUNTIME_SOURCE_DIR)/$$path" "$(DESTDIR)$(RUNTIMEDIR)/$$path"; \ - done - @for path in $(RUNTIME_OPTIONAL_DATA_FILES); do \ - if test -e "$(RUNTIME_SOURCE_DIR)/$$path"; then \ - $(INSTALL) -m 0644 "$(RUNTIME_SOURCE_DIR)/$$path" "$(DESTDIR)$(RUNTIMEDIR)/$$path"; \ - fi; \ - done - chmod 0600 "$(DESTDIR)$(RUNTIMEDIR)/id_ed25519" - cp -a "$(RUNTIME_SOURCE_DIR)/$(RUNTIME_MODULES_DIR)" "$(DESTDIR)$(RUNTIMEDIR)/wtf/root/lib/modules/" + $(INSTALL) -m 0755 "$(VSOCK_AGENT_BIN)" "$(DESTDIR)$(LIBDIR)/banger/banger-vsock-agent" rootfs: - BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./scripts/make-rootfs.sh + BANGER_MANUAL_DIR="$(abspath $(BUILD_MANUAL_DIR))" BANGER_BIN="$(abspath $(BANGER_BIN))" ./scripts/make-rootfs.sh $(ARGS) void-kernel: - BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./scripts/make-void-kernel.sh + BANGER_MANUAL_DIR="$(abspath $(BUILD_MANUAL_DIR))" ./scripts/make-void-kernel.sh $(ARGS) rootfs-void: - BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" ./scripts/make-rootfs-void.sh + BANGER_MANUAL_DIR="$(abspath $(BUILD_MANUAL_DIR))" BANGER_BIN="$(abspath $(BANGER_BIN))" ./scripts/make-rootfs-void.sh $(ARGS) void-register: build - BANGER_RUNTIME_DIR="$(abspath $(RUNTIME_SOURCE_DIR))" VOID_IMAGE_NAME="$(VOID_IMAGE_NAME)" BANGER_BIN="$(abspath $(BANGER_BIN))" ./scripts/register-void-image.sh + BANGER_MANUAL_DIR="$(abspath $(BUILD_MANUAL_DIR))" VOID_IMAGE_NAME="$(VOID_IMAGE_NAME)" BANGER_BIN="$(abspath $(BANGER_BIN))" ./scripts/register-void-image.sh void-vm: void-register "$(abspath $(BANGER_BIN))" vm create --image "$(VOID_IMAGE_NAME)" --name "$(VOID_VM_NAME)" diff --git a/README.md b/README.md index b6b9235..88f8914 100644 --- a/README.md +++ b/README.md @@ -1,520 +1,196 @@ # banger -Persistent Firecracker development VMs managed through a Go daemon and CLI. +`banger` manages Firecracker development VMs with a local daemon, managed image artifacts, and a localhost web UI. ## Requirements -- Linux host with KVM (`/dev/kvm` access) -- Vsock support for post-SSH liveness reminders (`/dev/vhost-vsock`) -- Core VM lifecycle: `sudo`, `ip`, `dmsetup`, `losetup`, `blockdev`, `truncate`, `pgrep`, `chown`, `chmod`, `kill` -- Guest rootfs patching: `e2cp`, `e2rm`, `debugfs` -- Guest work disk creation/resizing: `mkfs.ext4`, `e2fsck`, `resize2fs`, `mount`, `umount`, `cp` -- SSH and logs: `ssh` -- Optional NAT: `iptables`, `sysctl` -- Image build: the bundled SSH key plus the tools above; `banger image build` no longer shells out through `customize.sh` -`banger` validates these per command and returns actionable errors instead of -assuming one workstation layout. +- Linux with `/dev/kvm` +- `sudo` +- Firecracker installed on `PATH`, or `firecracker_bin` set in config +- The usual host tools checked by `./build/bin/banger doctor` -## Runtime Bundle -Runtime artifacts are no longer tracked directly in Git. Source checkouts use a -generated `./build/runtime/` bundle, while installed binaries use -`$(prefix)/lib/banger`. +`banger` now owns complete managed image sets. A managed image includes: -The bundle contains: -- `firecracker` -- `banger-vsock-agent` for the guest-side vsock HTTP health agent and SSH reminder checks -- `bundle.json` with the bundle's default kernel/initrd/modules/rootfs paths -- a kernel, initrd, and modules tree referenced by `bundle.json` -- `rootfs-docker.ext4` -- `rootfs-docker.work-seed.ext4` when present, used to seed `/root` quickly on - new VM creates -- `rootfs.ext4` when present -- `packages.apt` -- `id_ed25519` -- the helper scripts used by manual customization and installs +- `rootfs` +- optional `work-seed` +- `kernel` +- optional `initrd` +- optional `modules` -Bootstrap a source checkout from a local or published runtime archive. The -checked-in [`config/runtime-bundle.toml`](/home/thales/projects/personal/banger/config/runtime-bundle.toml) -is a template and intentionally ships with empty `url` and `sha256`. - -If you need to create a local archive first, do that from a checkout or machine -that already has a populated `./build/runtime/` tree: -```bash -make runtime-package -cp build/dist/banger-runtime.tar.gz /path/to/fresh-checkout/build/dist/ -``` - -In the fresh checkout: -```bash -cp config/runtime-bundle.toml config/runtime-bundle.local.toml -``` - -Edit `config/runtime-bundle.local.toml` to point at the staged archive and checksum: -```toml -url = "./build/dist/banger-runtime.tar.gz" -sha256 = "" -``` - -Then bootstrap `./build/runtime/` with the local manifest copy: -```bash -make runtime-bundle RUNTIME_MANIFEST=config/runtime-bundle.local.toml -``` - -`url` may be a relative path, absolute path, `file:///...` URL, or HTTP(S) -URL. `make install` will not fetch artifacts for you. +There is no runtime bundle anymore. ## Build + ```bash make build ``` -Run `make build` after `./build/runtime/` has been bootstrapped. It writes -`./build/bin/banger`, `./build/bin/bangerd`, and refreshes the bundled -`banger-vsock-agent` guest helper in `./build/runtime/`. +This writes: -Older ignored root artifacts such as `./runtime/`, `./banger`, and `./bangerd` -are no longer the canonical source-checkout layout. Leave them alone if you -still need them, or remove them manually after migrating to `build/`. +- `./build/bin/banger` +- `./build/bin/bangerd` +- `./build/bin/banger-vsock-agent` -If you have confirmed your current images and runtime settings no longer point -at the old checkout-local paths, a one-time cleanup looks like: -```bash -rm -rf ./runtime ./banger ./bangerd -``` +## Install -Install into `~/.local/bin` by default, with the runtime bundle under -`~/.local/lib/banger`: ```bash make install ``` -After `make install`, the installed `banger` and `bangerd` do not need the repo -checkout to keep working. +That installs: -## Basic VM Workflow -Create and boot a VM: -```bash -banger vm create --name calm-otter --disk-size 16G -``` +- `banger` +- `bangerd` +- the `banger-vsock-agent` companion helper under `../lib/banger/` -`banger vm create` now waits for full guest readiness by default, including the -guest vsock agent and the default `opencode` service, and prints live progress -stages on TTY stderr while it waits. +## Config -Check host/runtime readiness before creating VMs: -```bash -banger doctor -``` +Config lives at `~/.config/banger/config.toml`. -List VMs: -```bash -banger vm list -``` +Supported keys: -Inspect a VM: -```bash -banger vm show calm-otter -banger vm stats calm-otter -``` - -SSH into a running VM: -```bash -banger vm ssh calm-otter -``` - -When the SSH session exits normally, `banger` checks the guest over vsock and -reminds you if the VM is still running. - -Inspect host-reachable listening ports for a running VM: -```bash -banger vm ports calm-otter -``` - -Stop, restart, kill, or delete it: -```bash -banger vm stop calm-otter -banger vm start calm-otter -banger vm restart calm-otter -banger vm kill --signal TERM calm-otter -banger vm delete calm-otter -``` - -Update stopped VM settings: -```bash -banger vm set calm-otter --memory 2048 --vcpu 4 --disk-size 32G -``` - -Lifecycle and `set` actions also accept multiple VM refs and run them -concurrently: -```bash -banger vm stop calm-otter buildbox api-1 -banger vm kill --signal KILL aa12bb34 cc56dd78 -banger vm set --nat web-1 web-2 web-3 -``` - -## Daemon -The CLI auto-starts `bangerd` when needed. - -Useful daemon commands: -```bash -banger daemon status -banger daemon socket -banger daemon stop -``` - -`banger daemon status` prints the daemon PID, socket path, daemon log path, and -the built-in DNS listener address. The daemon also serves a local web UI on -`http://127.0.0.1:7777` by default, and `daemon status` prints that URL when it -is enabled. - -Use the web UI for dashboard, VM lifecycle, image inventory, VM create -progress, ports/log inspection, and image build/register/promote/delete flows: -```text -http://127.0.0.1:7777 -``` - -The image forms use a server-side host-path picker. They do not upload files -through the browser; they select absolute paths that already exist on the host. -Mutating actions in the UI require the same sudo readiness as the CLI-backed -workflow. If the page shows writes as disabled, run: -```bash -sudo -v -``` -and refresh the page. - -State lives under XDG directories: -- config: `~/.config/banger` -- state: `~/.local/state/banger` -- cache: `~/.cache/banger` -- runtime socket: `$XDG_RUNTIME_DIR/banger/bangerd.sock` - -Installed binaries resolve their runtime bundle from `../lib/banger` relative to -the executable. Source-checkout binaries resolve it from `./build/runtime` next -to `./build/bin/banger`, and still fall back to a legacy `./runtime` checkout -bundle if that exists. You can override either with `runtime_dir` in -`~/.config/banger/config.toml` or `BANGER_RUNTIME_DIR`. - -Useful config keys: - `log_level` -- `runtime_dir` -- `web_listen_addr` (`""` disables the web UI) -- `tap_pool_size` +- `web_listen_addr` - `firecracker_bin` -- `namegen_path` -- `customize_script` (manual helper compatibility; `banger image build` is Go-native) -- `vsock_agent_path` -- `default_rootfs` -- `default_work_seed` -- `default_base_rootfs` -- `default_kernel` -- `default_initrd` -- `default_modules_dir` -- `default_packages_file` +- `ssh_key_path` +- `default_image_name` +- `auto_stop_stale_after` +- `stats_poll_interval` +- `metrics_poll_interval` +- `bridge_name` +- `bridge_ip` +- `cidr` +- `tap_pool_size` +- `default_dns` -Guest SSH access always uses the private key shipped in the resolved runtime -bundle. `ssh_key_path` is no longer a supported override for `banger vm ssh`, -VM start key injection, or daemon guest provisioning. +If `ssh_key_path` is unset, banger creates and uses: -## Doctor -`banger doctor` runs the same readiness checks the Go control plane uses for VM -start, host-integrated features, and image builds. It reports runtime bundle -state, core VM host tools, current feature readiness, and image-build -prerequisites in a concise pass/warn/fail list. +- `~/.config/banger/ssh/id_ed25519` -Use it when bringing up a new machine, after changing the runtime bundle, or -before adding new host-integrated VM features. +`default_image_name` now only means “use this registered image when `vm create` omits `--image`”. The daemon does not auto-register images from host paths. -## Logs -- daemon lifecycle logs: `~/.local/state/banger/bangerd.log` -- raw Firecracker output per VM: `~/.local/state/banger/vms//firecracker.log` -- raw image-build helper output: `~/.local/state/banger/image-build/*.log` +## Core Workflow -`bangerd.log` is structured JSON. Set `log_level` in -`~/.config/banger/config.toml` or `BANGER_LOG_LEVEL` to one of `debug`, -`info`, `warn`, or `error`. +Check the host: -## Images -List images: ```bash -banger image list +./build/bin/banger doctor ``` -Build a managed image: +Register an existing host-side image stack: + ```bash -banger image build --name docker-dev --docker +./build/bin/banger image register \ + --name base \ + --rootfs /abs/path/rootfs.ext4 \ + --kernel /abs/path/vmlinux \ + --initrd /abs/path/initrd.img \ + --modules /abs/path/modules ``` -The web UI exposes both managed image build and unmanaged image register forms. -Builds run through an async progress page; register, promote, and delete remain -direct form actions. +Build a managed image from an existing registered image: -Rebuilt images install a pinned `mise` at `/usr/local/bin/mise`, activate it -for bash login and interactive shells, install `opencode` through `mise`, -expose `/usr/local/bin/opencode`, configure `tmux-resurrect` plus -`tmux-continuum` for `root` with periodic autosaves and manual-only restore by -default, start a host-reachable `opencode serve` service on guest TCP port -`4096`, and bake in the `banger-vsock-agent` systemd service used by the -post-SSH reminder path and guest health checks. They -also emit a `work-seed.ext4` sidecar that lets new VMs clone a prepared `/root` -work disk instead of rebuilding it from scratch on every create. - -Show or delete images: ```bash -banger image show docker-dev -banger image delete docker-dev +./build/bin/banger image build \ + --name devbox \ + --from-image base \ + --docker ``` -Promote an existing unmanaged image into a managed one: +Promote an unmanaged image into daemon-owned managed artifacts: + ```bash -banger image promote default -banger image promote void-exp +./build/bin/banger image promote base ``` -Promotion copies the image's `rootfs` and optional `work-seed` into the -daemon's managed image state directory and keeps the same image ID, so existing -VM references stay valid. The image's kernel, initrd, modules, and package -manifest paths stay pointed at their current locations. +Create and use a VM: -`banger` auto-registers the bundled `default_rootfs` image when it exists. If -the bundle does not include a separate base `rootfs.ext4`, `image build` falls -back to using `rootfs-docker.ext4` as its default base image. - -## Networking And DNS -Enable NAT when creating or updating a VM: ```bash -banger vm create --name web --nat -banger vm set web --nat -banger vm set web --no-nat +./build/bin/banger vm create --image devbox --name testbox +./build/bin/banger vm ssh testbox +./build/bin/banger vm stop testbox ``` -NAT is applied by the Go control plane using host `iptables` rules derived from -the VM's current guest IP and TAP device. The remaining shell helpers also -route NAT changes through `banger` instead of a standalone shell NAT script. +`vm create` stays synchronous by default, but on a TTY it now shows live progress until the VM is fully ready. -`bangerd` also serves a tiny authoritative DNS service on `127.0.0.1:42069` -for daemon-managed VMs. Known `A` records resolve `.vm` to the VM's -guest IPv4 address. Integrate your local resolver separately if you want -transparent `.vm` lookups on the host. +## Web UI -`banger vm ports` asks the guest-side `banger-vsock-agent` to run `ss`, then -prints host-usable endpoints plus the owning process/command. TCP listeners get -short best-effort HTTP and HTTPS probes; detected web listeners are shown as -`http` or `https`, and the endpoint column becomes a clickable URL such as -`https://.vm:port/`. Older images without `ss` may need rebuilding -before `vm ports` works. +`bangerd` serves a local web UI by default at: -Newly rebuilt images also start `opencode serve` by default on guest TCP port -`4096`, bound on guest interfaces so the host can reach it directly at the -guest IP or via the endpoint shown by `banger vm ports`. +- `http://127.0.0.1:7777` -## Storage Model -- VMs share a read-only base rootfs image. -- Each VM gets its own sparse writable system overlay for `/`. -- Each VM gets its own persistent ext4 work disk mounted at `/root`. -- When an image has a `work-seed.ext4` sidecar, new VM creates clone that seed - and only resize it when needed. -- Older managed images without the seeded SSH metadata may take one slower - create to repair `/root` access and refresh their managed work-seed; later - creates use the fast path. -- Images without any `work-seed.ext4` still work, but create more slowly - because `/root` must be built from scratch. -- The daemon can keep a small idle TAP pool warm in the background so VM create - does not need to synchronously create a fresh TAP every time. `tap_pool_size` - controls the pool depth. +See the effective URL with: -## Architecture Notes -The Go daemon is the primary control plane. VM host integrations such as the -built-in `.vm` DNS service, NAT, and `/root` work-disk wiring now sit behind a -capability pipeline in the daemon instead of being open-coded through the VM -lifecycle. Guest boot-time files and mounts are rendered through a structured -guest-config builder rather than ad hoc `fstab` string mutation. - -That split is intentional: future host-integrated features should plug into the -daemon capability path and `banger doctor` checks first, with the remaining -shell helpers treated as manual workflows rather than architecture drivers. -- Stopping a VM preserves its overlay and work disk. - -## Rebuilding The Repo Default Rootfs -`config/packages.apt` controls the base apt packages baked into rebuilt images, -including guest tools such as `ss` used by `banger vm ports`. - -To rebuild the source-checkout default image in `./build/runtime/rootfs-docker.ext4`: ```bash -make rootfs +./build/bin/banger daemon status ``` -That rebuild also regenerates `./build/runtime/rootfs-docker.work-seed.ext4`, which -the daemon uses to speed up future `vm create` calls, and bakes in the default -host-reachable `opencode` server service. +Disable it with: -If your runtime bundle does not include `./build/runtime/rootfs.ext4`, pass an -explicit base image instead: -```bash -./scripts/make-rootfs.sh --base-rootfs /path/to/base-rootfs.ext4 +```toml +web_listen_addr = "" ``` -If the package manifest changed and you want a fresh source-checkout image: +## Guest Services + +Provisioned images include: + +- `banger-vsock-agent` +- guest networking bootstrap +- `mise` +- `opencode` +- a default guest `opencode` service on `0.0.0.0:4096` + +From the host: + ```bash -rm -f ./build/runtime/rootfs-docker.ext4 ./build/runtime/rootfs-docker.ext4.packages.sha256 -make rootfs +./build/bin/banger vm ports testbox +opencode attach http://:4096 ``` -`make rootfs` expects a bootstrapped runtime bundle. If `./build/runtime/rootfs.ext4` -is not available, pass an explicit `--base-rootfs` to `./scripts/make-rootfs.sh`. -Existing VMs keep using their current image and disks; rebuilds only affect VMs -created from the rebuilt image afterward. Restarting an existing VM is not -enough to pick up guest provisioning changes such as the default `opencode` -server service. +## Manual Helpers + +The shell helpers are now explicit manual workflows under `./build/manual`. + +Rebuild a Debian-style manual rootfs: + +```bash +make rootfs ARGS='--base-rootfs /abs/path/rootfs.ext4 --kernel /abs/path/vmlinux --initrd /abs/path/initrd.img --modules /abs/path/modules' +``` + +The output lands in: + +- `./build/manual/rootfs-docker.ext4` +- `./build/manual/rootfs-docker.work-seed.ext4` + +## Experimental Void Flow + +Stage a Void kernel: -## Experimental Void Rootfs -There is also a separate, opt-in builder for an experimental Void Linux guest -path: ```bash make void-kernel +``` + +Build the experimental Void rootfs: + +```bash make rootfs-void ``` -That writes: -- `./build/runtime/void-kernel/` when `make void-kernel` is used -- `./build/runtime/rootfs-void.ext4` -- `./build/runtime/rootfs-void.work-seed.ext4` +Register it: -This path is intentionally local-only and does not change the default Debian -image flow. `make void-kernel` stages an actual Void `linux6.12` kernel package -under `./build/runtime/void-kernel/`, including the raw `vmlinuz`, extracted -Firecracker `vmlinux`, a matching `initramfs`, the matching config, and the -matching modules tree. The initramfs is generated locally with `dracut` -against the downloaded Void sysroot so the kernel, initrd, and modules stay -aligned. `make rootfs-void` then prefers that staged modules tree when it exists; -otherwise it falls back to the runtime bundle modules. The rootfs builder -itself still builds a lean `x86_64-glibc` Void userspace with: -- `bash` installed for interactive/admin use -- pinned `mise` installed at `/usr/local/bin/mise`, activated for `root` bash shells -- `opencode` installed through `mise`, with `/usr/local/bin/opencode` available by default -- a guest network bootstrap that configures the VM NIC from the kernel `ip=` boot arg -- a host-reachable `opencode serve` runit service enabled on guest TCP port `4096` -- `docker` plus `docker-compose` installed from Void packages -- the `docker` runit service enabled, with Docker netfilter/forwarding kernel prep -- `openssh` enabled under runit -- the bundled `banger-vsock-agent` health agent enabled under runit -- `root` normalized to `/bin/bash` while keeping `/bin/sh` as the distro's system shell -- a generated `/root` work-seed for fast creates - -It still keeps some Debian-oriented extras out for now: -- no tmux plugin defaults - -The builder fetches official static XBPS tools and packages from the Void -mirror during the build. The kernel fetcher and rootfs builder currently -support only `x86_64`. - -The package set comes from [`config/packages.void`](/home/thales/projects/personal/banger/config/packages.void). -You can override the mirror, size, output path, or kernel package directly: ```bash -./scripts/make-void-kernel.sh --kernel-package linux6.12 -./scripts/make-rootfs-void.sh --mirror https://repo-default.voidlinux.org --size 2G -``` - -The fastest local iteration loop does not require changing your default image -config at all: -```bash -make void-kernel -make rootfs-void make void-register -./build/bin/banger vm create --image void-exp --name void-dev -./build/bin/banger vm ssh void-dev ``` -Rebuild the staged Void kernel or Void rootfs, then recreate existing -`void-exp` VMs after changing the package set, guest provisioning, or staged -kernel artifacts; restart alone will not update the image contents, kernel, or -`/root` work-seed. +That flow uses: -There is also a smoke path for the experimental image: -```bash -make verify-void -``` +- `./build/manual/void-kernel/` +- `./build/manual/rootfs-void.ext4` +- `./build/manual/rootfs-void.work-seed.ext4` -`make void-register` uses the unmanaged image registration path to create or -update a `void-exp` image record in place, so repeated rebuilds do not require -editing `~/.config/banger/config.toml`. It expects a complete staged Void -kernel set under `./build/runtime/void-kernel/` and points the experimental image at -the staged Void `vmlinux`, `initramfs`, and matching modules tree. +## Notes -There is also a one-step helper target: -```bash -make void-vm VOID_VM_NAME=void-a -``` - -If you really want the Void image to become your default for `vm create` -without `--image`, use the checked-in override template at -[`examples/void-exp.config.toml`](/home/thales/projects/personal/banger/examples/void-exp.config.toml) -and merge its four settings into `~/.config/banger/config.toml`. - -`banger image build` remains Debian-only in this pass. Do not point -`default_base_rootfs` at the Void artifact yet. - -## Registering Unmanaged Images -You can also register any local rootfs as an unmanaged image record without -changing global defaults: -```bash -banger image register --name local-test --rootfs /abs/path/rootfs.ext4 -``` - -Optional paths let you point at an existing work seed, kernel, initrd, modules, -and package manifest: -```bash -banger image register \ - --name void-exp \ - --rootfs ./build/runtime/rootfs-void.ext4 \ - --work-seed ./build/runtime/rootfs-void.work-seed.ext4 \ - --kernel ./build/runtime/void-kernel/boot/vmlinux-6.12.77_1 \ - --initrd ./build/runtime/void-kernel/boot/initramfs-6.12.77_1.img \ - --modules ./build/runtime/void-kernel/lib/modules/6.12.77_1 \ - --packages ./config/packages.void -``` - -If an unmanaged image with the same name already exists, `image register` -updates it in place so future `vm create --image ` calls pick up the new -artifacts immediately. - -## Maintaining The Runtime Bundle -The checked-in [`config/runtime-bundle.toml`](/home/thales/projects/personal/banger/config/runtime-bundle.toml) -is a template. Keep `bundle_metadata` accurate there, but use a separate local -manifest copy when you need concrete `url` and `sha256` values for bootstrap -testing or publication. - -Package a local `./build/runtime/` tree into an archive: -```bash -make runtime-package -``` - -That writes `build/dist/banger-runtime.tar.gz` and prints its SHA256 so you can update -a local manifest copy before testing bootstrap changes or publishing the -archive elsewhere. - -## Benchmarking Create Time -Benchmark the current host's `vm create` wall time plus first-SSH readiness: -```bash -make bench-create -``` - -Pass options through `ARGS`, for example: -```bash -make bench-create ARGS="--runs 3 --image docker-dev" -``` - -The benchmark prints JSON with: -- `create_ms`: wall time for `banger vm create`, including full readiness - gating for the guest vsock agent and default `opencode` service -- `ssh_ready_ms`: wall time from create start until `banger vm ssh -- true` - succeeds - -## Remaining Shell Helpers -The runtime VM lifecycle is managed through `banger`. The remaining shell scripts are not the primary user interface: -- `scripts/customize.sh`: manual reference flow for rootfs customization; `banger image build` is now Go-native, but the script still reads - assets from `BANGER_RUNTIME_DIR` and stores transient state under - `BANGER_STATE_DIR`/XDG state -- `scripts/make-rootfs.sh`: convenience wrapper for rebuilding `./build/runtime/rootfs-docker.ext4` -- `scripts/interactive.sh`: manual one-off rootfs customization over SSH -- `scripts/lib/packages.sh`: shell helper library -- `scripts/verify.sh`: smoke test for the Go workflow (`./scripts/verify.sh --nat` adds NAT coverage) +- Firecracker is resolved from `PATH` by default. +- Managed image delete removes the daemon-owned artifact dir. +- The companion vsock helper is internal to the install/build layout, not a user-configured runtime path. diff --git a/cmd/runtimebundle/main.go b/cmd/runtimebundle/main.go deleted file mode 100644 index 28da9ea..0000000 --- a/cmd/runtimebundle/main.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "os" - - "banger/internal/runtimebundle" -) - -const ( - defaultManifestPath = "config/runtime-bundle.toml" - defaultRuntimeDir = "build/runtime" - defaultArchivePath = "build/dist/banger-runtime.tar.gz" -) - -func main() { - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - switch os.Args[1] { - case "fetch": - if err := fetch(os.Args[2:]); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - case "package": - if err := pkg(os.Args[2:]); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - default: - usage() - os.Exit(2) - } -} - -func fetch(args []string) error { - fs := flag.NewFlagSet("fetch", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - manifestPath := fs.String("manifest", defaultManifestPath, "path to the runtime bundle manifest") - outDir := fs.String("out", defaultRuntimeDir, "destination runtime directory") - if err := fs.Parse(args); err != nil { - return err - } - manifest, err := runtimebundle.LoadManifest(*manifestPath) - if err != nil { - return err - } - return runtimebundle.Bootstrap(context.Background(), manifest, *manifestPath, *outDir) -} - -func pkg(args []string) error { - fs := flag.NewFlagSet("package", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - manifestPath := fs.String("manifest", defaultManifestPath, "path to the runtime bundle manifest") - runtimeDir := fs.String("runtime-dir", defaultRuntimeDir, "runtime directory to package") - outArchive := fs.String("out", defaultArchivePath, "output archive path") - if err := fs.Parse(args); err != nil { - return err - } - manifest, err := runtimebundle.LoadManifest(*manifestPath) - if err != nil { - return err - } - sum, err := runtimebundle.Package(*runtimeDir, *outArchive, manifest) - if err != nil { - return err - } - fmt.Println(sum) - return nil -} - -func usage() { - fmt.Fprintln(os.Stderr, "usage: runtimebundle [flags]") -} diff --git a/config/packages.apt b/config/packages.apt deleted file mode 100644 index 54a5159..0000000 --- a/config/packages.apt +++ /dev/null @@ -1,10 +0,0 @@ -make -git -less -tree -ca-certificates -curl -wget -iproute2 -vim -tmux diff --git a/config/packages.void b/config/packages.void deleted file mode 100644 index 2b3b99e..0000000 --- a/config/packages.void +++ /dev/null @@ -1,27 +0,0 @@ -base-minimal -base-devel -bash -openssh -ca-certificates -curl -docker -docker-compose -fd -fzf -git -iputils -jq -kmod -iproute2 -less -lsof -make -procps-ng -psmisc -ripgrep -strace -tmux -vim -unzip -zip -zstd diff --git a/config/runtime-bundle.toml b/config/runtime-bundle.toml deleted file mode 100644 index 460cb53..0000000 --- a/config/runtime-bundle.toml +++ /dev/null @@ -1,33 +0,0 @@ -# Template manifest for local or published runtime bundle archives. -# Keep this checked-in file empty by default; use a local manifest copy with -# concrete `url` and `sha256` values when bootstrapping `./build/runtime/`. -version = "v0" -url = "" -sha256 = "" -bundle_root = "runtime" -required_paths = [ - "firecracker", - "customize.sh", - "packages.sh", - "namegen", - "banger-vsock-agent", - "packages.apt", - "id_ed25519", - "rootfs-docker.ext4", - "wtf/root/boot/vmlinux-6.8.0-94-generic", - "wtf/root/boot/initrd.img-6.8.0-94-generic", - "wtf/root/lib/modules/6.8.0-94-generic", -] - -[bundle_metadata] -firecracker_bin = "firecracker" -ssh_key_path = "id_ed25519" -namegen_path = "namegen" -customize_script = "customize.sh" -vsock_agent_path = "banger-vsock-agent" -default_packages_file = "packages.apt" -default_rootfs = "rootfs-docker.ext4" -default_work_seed = "rootfs-docker.work-seed.ext4" -default_kernel = "wtf/root/boot/vmlinux-6.8.0-94-generic" -default_initrd = "wtf/root/boot/initrd.img-6.8.0-94-generic" -default_modules_dir = "wtf/root/lib/modules/6.8.0-94-generic" diff --git a/examples/void-exp.config.toml b/examples/void-exp.config.toml index 192f433..1266ada 100644 --- a/examples/void-exp.config.toml +++ b/examples/void-exp.config.toml @@ -1,14 +1,9 @@ # Experimental Void Linux guest profile for local testing. # -# Copy the values you want into ~/.config/banger/config.toml and replace -# /abs/path/to/banger with your checkout path. Do not set default_base_rootfs -# to the Void image yet; banger image build still assumes the Debian flow. -# If you run `make void-kernel`, also merge the commented kernel/initrd/modules lines. +# Register or promote a complete `void-exp` image first, then point the daemon +# at it by name. Firecracker is resolved from PATH by default; set +# `firecracker_bin` only if you need an override. -runtime_dir = "/abs/path/to/banger/build/runtime" default_image_name = "void-exp" -default_rootfs = "/abs/path/to/banger/build/runtime/rootfs-void.ext4" -default_work_seed = "/abs/path/to/banger/build/runtime/rootfs-void.work-seed.ext4" -# default_kernel = "/abs/path/to/banger/build/runtime/void-kernel/boot/vmlinux-6.12.77_1" -# default_initrd = "/abs/path/to/banger/build/runtime/void-kernel/boot/initramfs-6.12.77_1.img" -# default_modules_dir = "/abs/path/to/banger/build/runtime/void-kernel/lib/modules/6.12.77_1" +# firecracker_bin = "/usr/bin/firecracker" +# ssh_key_path = "/abs/path/to/private/key" diff --git a/internal/api/types.go b/internal/api/types.go index ca44542..fcd6961 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -149,7 +149,7 @@ type VMPortsResult struct { type ImageBuildParams struct { Name string `json:"name,omitempty"` - BaseRootfs string `json:"base_rootfs,omitempty"` + FromImage string `json:"from_image,omitempty"` Size string `json:"size,omitempty"` KernelPath string `json:"kernel_path,omitempty"` InitrdPath string `json:"initrd_path,omitempty"` @@ -164,7 +164,6 @@ type ImageRegisterParams struct { KernelPath string `json:"kernel_path,omitempty"` InitrdPath string `json:"initrd_path,omitempty"` ModulesDir string `json:"modules_dir,omitempty"` - PackagesPath string `json:"packages_path,omitempty"` Docker bool `json:"docker,omitempty"` } diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 89154e0..761172b 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -20,6 +20,7 @@ import ( "banger/internal/config" "banger/internal/daemon" "banger/internal/hostnat" + "banger/internal/imagepreset" "banger/internal/model" "banger/internal/paths" "banger/internal/rpc" @@ -101,7 +102,104 @@ func newInternalCommand() *cobra.Command { Hidden: true, RunE: helpNoArgs, } - cmd.AddCommand(newInternalNATCommand(), newInternalWorkSeedCommand()) + cmd.AddCommand( + newInternalNATCommand(), + newInternalWorkSeedCommand(), + newInternalSSHKeyPathCommand(), + newInternalFirecrackerPathCommand(), + newInternalVSockAgentPathCommand(), + newInternalPackagesCommand(), + ) + return cmd +} + +func newInternalSSHKeyPathCommand() *cobra.Command { + return &cobra.Command{ + Use: "ssh-key-path", + Hidden: true, + Args: noArgsUsage("usage: banger internal ssh-key-path"), + RunE: func(cmd *cobra.Command, args []string) error { + layout, err := paths.Resolve() + if err != nil { + return err + } + cfg, err := config.Load(layout) + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), cfg.SSHKeyPath) + return err + }, + } +} + +func newInternalFirecrackerPathCommand() *cobra.Command { + return &cobra.Command{ + Use: "firecracker-path", + Hidden: true, + Args: noArgsUsage("usage: banger internal firecracker-path"), + RunE: func(cmd *cobra.Command, args []string) error { + layout, err := paths.Resolve() + if err != nil { + return err + } + cfg, err := config.Load(layout) + if err != nil { + return err + } + if strings.TrimSpace(cfg.FirecrackerBin) == "" { + return errors.New("firecracker binary not configured; install firecracker or set firecracker_bin") + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), cfg.FirecrackerBin) + return err + }, + } +} + +func newInternalVSockAgentPathCommand() *cobra.Command { + return &cobra.Command{ + Use: "vsock-agent-path", + Hidden: true, + Args: noArgsUsage("usage: banger internal vsock-agent-path"), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := paths.CompanionBinaryPath("banger-vsock-agent") + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), path) + return err + }, + } +} + +func newInternalPackagesCommand() *cobra.Command { + var docker bool + cmd := &cobra.Command{ + Use: "packages ", + Hidden: true, + Args: exactArgsUsage(1, "usage: banger internal packages [--docker]"), + RunE: func(cmd *cobra.Command, args []string) error { + var packages []string + switch strings.TrimSpace(args[0]) { + case "debian": + packages = imagepreset.DebianBasePackages() + if docker { + packages = append(packages, "docker.io") + } + case "void": + packages = imagepreset.VoidBasePackages() + default: + return fmt.Errorf("unknown package preset %q", args[0]) + } + for _, pkg := range packages { + if _, err := fmt.Fprintln(cmd.OutOrStdout(), pkg); err != nil { + return err + } + } + return nil + }, + } + cmd.Flags().BoolVar(&docker, "docker", false, "include docker-specific additions") return cmd } @@ -630,7 +728,7 @@ func newImageBuildCommand() *cobra.Command { }, } cmd.Flags().StringVar(¶ms.Name, "name", "", "image name") - cmd.Flags().StringVar(¶ms.BaseRootfs, "base-rootfs", "", "base rootfs path") + cmd.Flags().StringVar(¶ms.FromImage, "from-image", "", "registered base image id or name") 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") @@ -644,7 +742,7 @@ func newImageRegisterCommand() *cobra.Command { cmd := &cobra.Command{ Use: "register", Short: "Register or update an unmanaged image", - Args: noArgsUsage("usage: banger image register --name --rootfs [--work-seed ] [--kernel ] [--initrd ] [--modules ] [--packages ]"), + Args: noArgsUsage("usage: banger image register --name --rootfs [--work-seed ] --kernel [--initrd ] [--modules ]"), RunE: func(cmd *cobra.Command, args []string) error { if err := absolutizeImageRegisterPaths(¶ms); err != nil { return err @@ -669,7 +767,6 @@ func newImageRegisterCommand() *cobra.Command { 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().StringVar(¶ms.PackagesPath, "packages", "", "packages manifest path") cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "mark image as docker-prepared") return cmd } @@ -1158,13 +1255,13 @@ func validateSSHPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("ssh", "install openssh-client") if strings.TrimSpace(cfg.SSHKeyPath) != "" { - checks.RequireFile(cfg.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`) + checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) } return checks.Err("ssh preflight failed") } func absolutizeImageBuildPaths(params *api.ImageBuildParams) error { - return absolutizePaths(¶ms.BaseRootfs, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir) + return absolutizePaths(¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir) } func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error { @@ -1174,7 +1271,6 @@ func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error { ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir, - ¶ms.PackagesPath, ) } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b0b0f63..f82b76d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -163,7 +163,7 @@ func TestImageRegisterFlagsExist(t *testing.T) { if err != nil { t.Fatalf("find register: %v", err) } - for _, flagName := range []string{"name", "rootfs", "work-seed", "kernel", "initrd", "modules", "packages", "docker"} { + for _, flagName := range []string{"name", "rootfs", "work-seed", "kernel", "initrd", "modules", "docker"} { if register.Flags().Lookup(flagName) == nil { t.Fatalf("missing flag %q", flagName) } @@ -427,7 +427,6 @@ func TestAbsolutizeImageRegisterPaths(t *testing.T) { KernelPath: filepath.Join(".", "runtime", "vmlinux"), InitrdPath: filepath.Join(".", "runtime", "initrd.img"), ModulesDir: filepath.Join(".", "runtime", "modules"), - PackagesPath: filepath.Join(".", "config", "packages.void"), } wd, err := os.Getwd() @@ -450,7 +449,6 @@ func TestAbsolutizeImageRegisterPaths(t *testing.T) { params.KernelPath, params.InitrdPath, params.ModulesDir, - params.PackagesPath, } { if !filepath.IsAbs(value) { t.Fatalf("path %q is not absolute", value) @@ -828,7 +826,7 @@ func TestAbsolutizeImageBuildPaths(t *testing.T) { }) params := api.ImageBuildParams{ - BaseRootfs: "images/base.ext4", + FromImage: "base-image", KernelPath: "/kernel", InitrdPath: "boot/initrd.img", ModulesDir: "modules", @@ -838,7 +836,7 @@ func TestAbsolutizeImageBuildPaths(t *testing.T) { } want := api.ImageBuildParams{ - BaseRootfs: filepath.Join(dir, "images/base.ext4"), + FromImage: "base-image", KernelPath: "/kernel", InitrdPath: filepath.Join(dir, "boot/initrd.img"), ModulesDir: filepath.Join(dir, "modules"), diff --git a/internal/config/config.go b/internal/config/config.go index ebdca41..bfaf926 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,38 +1,29 @@ package config import ( - "errors" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" "os" "path/filepath" "strings" "time" toml "github.com/pelletier/go-toml" + "golang.org/x/crypto/ssh" "banger/internal/model" "banger/internal/paths" - "banger/internal/runtimebundle" + "banger/internal/system" ) type fileConfig struct { - RuntimeDir string `toml:"runtime_dir"` - RepoRoot string `toml:"repo_root"` LogLevel string `toml:"log_level"` WebListenAddr *string `toml:"web_listen_addr"` FirecrackerBin string `toml:"firecracker_bin"` SSHKeyPath string `toml:"ssh_key_path"` - NamegenPath string `toml:"namegen_path"` - CustomizeScript string `toml:"customize_script"` - VSockAgent string `toml:"vsock_agent_path"` - VSockPingHelper string `toml:"vsock_ping_helper_path"` - DefaultWorkSeed string `toml:"default_work_seed"` DefaultImageName string `toml:"default_image_name"` - DefaultRootfs string `toml:"default_rootfs"` - 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"` @@ -58,202 +49,130 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { DefaultImageName: "default", } - path := filepath.Join(layout.ConfigDir, "config.toml") - info, err := os.Stat(path) var file fileConfig - if err != nil { - if !os.IsNotExist(err) { - return cfg, err - } - } else if !info.IsDir() { - data, err := os.ReadFile(path) + configPath := filepath.Join(layout.ConfigDir, "config.toml") + if info, err := os.Stat(configPath); err == nil && !info.IsDir() { + data, err := os.ReadFile(configPath) if err != nil { return cfg, err } if err := toml.Unmarshal(data, &file); err != nil { return cfg, err } - } - - cfg.RuntimeDir = paths.ResolveRuntimeDir(file.RuntimeDir, file.RepoRoot) - if err := applyRuntimeDefaults(&cfg); err != nil { + } else if err != nil && !os.IsNotExist(err) { return cfg, err } - if file.FirecrackerBin != "" { - cfg.FirecrackerBin = file.FirecrackerBin - } - if file.LogLevel != "" { - cfg.LogLevel = file.LogLevel + if value := strings.TrimSpace(file.LogLevel); value != "" { + cfg.LogLevel = value } if file.WebListenAddr != nil { cfg.WebListenAddr = strings.TrimSpace(*file.WebListenAddr) } - if file.NamegenPath != "" { - cfg.NamegenPath = file.NamegenPath + if value := strings.TrimSpace(file.FirecrackerBin); value != "" { + cfg.FirecrackerBin = value + } else if path, err := system.LookupExecutable("firecracker"); err == nil { + cfg.FirecrackerBin = path } - if file.CustomizeScript != "" { - cfg.CustomizeScript = file.CustomizeScript + if value := strings.TrimSpace(file.DefaultImageName); value != "" { + cfg.DefaultImageName = value } - if file.VSockAgent != "" { - cfg.VSockAgentPath = file.VSockAgent - } else if file.VSockPingHelper != "" { - cfg.VSockAgentPath = file.VSockPingHelper + if value := strings.TrimSpace(file.BridgeName); value != "" { + cfg.BridgeName = value } - if file.DefaultWorkSeed != "" { - cfg.DefaultWorkSeed = file.DefaultWorkSeed + if value := strings.TrimSpace(file.BridgeIP); value != "" { + cfg.BridgeIP = value } - if file.DefaultImageName != "" { - cfg.DefaultImageName = file.DefaultImageName - } - if file.DefaultRootfs != "" { - cfg.DefaultRootfs = file.DefaultRootfs - } - 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 value := strings.TrimSpace(file.CIDR); value != "" { + cfg.CIDR = value } if file.TapPoolSize > 0 { cfg.TapPoolSize = file.TapPoolSize } - if file.DefaultDNS != "" { - cfg.DefaultDNS = file.DefaultDNS + if value := strings.TrimSpace(file.DefaultDNS); value != "" { + cfg.DefaultDNS = value } - if file.AutoStopStaleAfter != "" { - duration, err := time.ParseDuration(file.AutoStopStaleAfter) + if value := strings.TrimSpace(file.AutoStopStaleAfter); value != "" { + duration, err := time.ParseDuration(value) if err != nil { return cfg, err } cfg.AutoStopStaleAfter = duration } - if file.StatsPollInterval != "" { - duration, err := time.ParseDuration(file.StatsPollInterval) + if value := strings.TrimSpace(file.StatsPollInterval); value != "" { + duration, err := time.ParseDuration(value) if err != nil { return cfg, err } cfg.StatsPollInterval = duration } - if file.MetricsPoll != "" { - duration, err := time.ParseDuration(file.MetricsPoll) + if value := strings.TrimSpace(file.MetricsPoll); value != "" { + duration, err := time.ParseDuration(value) if err != nil { return cfg, err } cfg.MetricsPollInterval = duration } - if value := os.Getenv("BANGER_LOG_LEVEL"); value != "" { + if value := strings.TrimSpace(os.Getenv("BANGER_LOG_LEVEL")); value != "" { cfg.LogLevel = value } + + sshKeyPath, err := resolveSSHKeyPath(layout, file.SSHKeyPath) + if err != nil { + return cfg, err + } + cfg.SSHKeyPath = sshKeyPath return cfg, nil } -func applyRuntimeDefaults(cfg *model.DaemonConfig) error { - if cfg.RuntimeDir == "" { - return nil +func resolveSSHKeyPath(layout paths.Layout, configured string) (string, error) { + configured = strings.TrimSpace(configured) + if configured != "" { + return configured, nil } - meta, err := runtimebundle.LoadBundleMetadata(cfg.RuntimeDir) - switch { - case err == nil: - applyBundleMetadataDefaults(cfg, cfg.RuntimeDir, meta) - case errors.Is(err, os.ErrNotExist): - applyLegacyRuntimeDefaults(cfg) - default: + return ensureDefaultSSHKey(filepath.Join(layout.ConfigDir, "ssh", "id_ed25519")) +} + +func ensureDefaultSSHKey(path string) (string, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", err + } + if _, err := os.Stat(path); err == nil { + if err := ensurePublicKeyFile(path); err != nil { + return "", err + } + return path, nil + } else if !os.IsNotExist(err) { + return "", err + } + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", err + } + pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return "", err + } + privatePEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}) + if err := os.WriteFile(path, privatePEM, 0o600); err != nil { + return "", err + } + if err := ensurePublicKeyFile(path); err != nil { + return "", err + } + return path, nil +} + +func ensurePublicKeyFile(privateKeyPath string) error { + data, err := os.ReadFile(privateKeyPath) + if err != nil { return err } - if cfg.DefaultRootfs == "" { - cfg.DefaultRootfs = firstExistingRuntimePath( - filepath.Join(cfg.RuntimeDir, "rootfs-docker.ext4"), - filepath.Join(cfg.RuntimeDir, "rootfs.ext4"), - ) + signer, err := ssh.ParsePrivateKey(data) + if err != nil { + return err } - if cfg.DefaultBaseRootfs == "" { - cfg.DefaultBaseRootfs = firstExistingRuntimePath( - filepath.Join(cfg.RuntimeDir, "rootfs.ext4"), - cfg.DefaultRootfs, - ) - } - if cfg.DefaultWorkSeed == "" && cfg.DefaultRootfs != "" { - cfg.DefaultWorkSeed = firstExistingRuntimePath(associatedWorkSeedPath(cfg.DefaultRootfs)) - } - return nil -} - -func applyBundleMetadataDefaults(cfg *model.DaemonConfig, runtimeDir string, meta runtimebundle.BundleMetadata) { - cfg.FirecrackerBin = defaultRuntimePath(cfg.FirecrackerBin, runtimeDir, meta.FirecrackerBin) - cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, runtimeDir, meta.SSHKeyPath) - cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, runtimeDir, meta.NamegenPath) - cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, runtimeDir, meta.CustomizeScript) - cfg.VSockAgentPath = defaultRuntimePath(cfg.VSockAgentPath, runtimeDir, meta.VSockAgentPath) - cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, runtimeDir, meta.DefaultWorkSeed) - cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, runtimeDir, meta.DefaultKernel) - cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, runtimeDir, meta.DefaultInitrd) - cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, runtimeDir, meta.DefaultModulesDir) - cfg.DefaultPackagesFile = defaultRuntimePath(cfg.DefaultPackagesFile, runtimeDir, meta.DefaultPackages) - cfg.DefaultRootfs = defaultRuntimePath(cfg.DefaultRootfs, runtimeDir, meta.DefaultRootfs) - cfg.DefaultBaseRootfs = defaultRuntimePath(cfg.DefaultBaseRootfs, runtimeDir, meta.DefaultBaseRootfs) -} - -func applyLegacyRuntimeDefaults(cfg *model.DaemonConfig) { - cfg.FirecrackerBin = defaultRuntimePath(cfg.FirecrackerBin, cfg.RuntimeDir, "firecracker") - cfg.SSHKeyPath = defaultRuntimePath(cfg.SSHKeyPath, cfg.RuntimeDir, "id_ed25519") - cfg.NamegenPath = defaultRuntimePath(cfg.NamegenPath, cfg.RuntimeDir, "namegen") - cfg.CustomizeScript = defaultRuntimePath(cfg.CustomizeScript, cfg.RuntimeDir, "customize.sh") - cfg.VSockAgentPath = firstExistingRuntimePath( - defaultRuntimePath(cfg.VSockAgentPath, cfg.RuntimeDir, "banger-vsock-agent"), - filepath.Join(cfg.RuntimeDir, "banger-vsock-pingd"), - ) - cfg.DefaultWorkSeed = defaultRuntimePath(cfg.DefaultWorkSeed, cfg.RuntimeDir, "rootfs-docker.work-seed.ext4") - cfg.DefaultKernel = defaultRuntimePath(cfg.DefaultKernel, cfg.RuntimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") - cfg.DefaultInitrd = defaultRuntimePath(cfg.DefaultInitrd, cfg.RuntimeDir, "wtf/root/boot/initrd.img-6.8.0-94-generic") - cfg.DefaultModulesDir = defaultRuntimePath(cfg.DefaultModulesDir, cfg.RuntimeDir, "wtf/root/lib/modules/6.8.0-94-generic") - cfg.DefaultPackagesFile = defaultRuntimePath(cfg.DefaultPackagesFile, cfg.RuntimeDir, "packages.apt") -} - -func defaultRuntimePath(current, runtimeDir, relative string) string { - if current != "" || relative == "" { - return current - } - return filepath.Join(runtimeDir, relative) -} - -func firstExistingRuntimePath(paths ...string) string { - for _, candidate := range paths { - if candidate == "" { - continue - } - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - return "" -} - -func associatedWorkSeedPath(rootfsPath string) string { - rootfsPath = strings.TrimSpace(rootfsPath) - if rootfsPath == "" { - return "" - } - if strings.HasSuffix(rootfsPath, ".ext4") { - return strings.TrimSuffix(rootfsPath, ".ext4") + ".work-seed.ext4" - } - return rootfsPath + ".work-seed" + publicKey := ssh.MarshalAuthorizedKey(signer.PublicKey()) + return os.WriteFile(privateKeyPath+".pub", publicKey, 0o644) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 665ab9b..1934c6a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,154 +1,70 @@ package config import ( - "encoding/json" "os" "path/filepath" "testing" + "time" "banger/internal/paths" - "banger/internal/runtimebundle" ) -func TestLoadDerivesArtifactPathsFromRuntimeDir(t *testing.T) { - runtimeDir := t.TempDir() - meta := runtimebundle.BundleMetadata{ - FirecrackerBin: "bin/firecracker", - SSHKeyPath: "keys/id_ed25519", - NamegenPath: "bin/namegen", - CustomizeScript: "scripts/customize.sh", - VSockAgentPath: "bin/banger-vsock-agent", - DefaultPackages: "config/packages.apt", - DefaultRootfs: "images/rootfs-docker.ext4", - DefaultWorkSeed: "images/rootfs-docker.work-seed.ext4", - DefaultKernel: "kernels/vmlinux", - DefaultInitrd: "kernels/initrd.img", - DefaultModulesDir: "modules/current", - } - for _, rel := range []string{ - meta.FirecrackerBin, - meta.SSHKeyPath, - meta.NamegenPath, - meta.CustomizeScript, - meta.VSockAgentPath, - meta.DefaultPackages, - meta.DefaultRootfs, - meta.DefaultWorkSeed, - meta.DefaultKernel, - meta.DefaultInitrd, - filepath.Join(meta.DefaultModulesDir, "modules.dep"), - } { - path := filepath.Join(runtimeDir, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) - } - if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } - } - data, err := json.Marshal(meta) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil { - t.Fatalf("write bundle metadata: %v", err) +func TestLoadDefaultsResolveFirecrackerAndGenerateSSHKey(t *testing.T) { + configDir := t.TempDir() + binDir := t.TempDir() + firecrackerPath := filepath.Join(binDir, "firecracker") + if err := os.WriteFile(firecrackerPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write firecracker: %v", err) } + t.Setenv("PATH", binDir) - t.Setenv("BANGER_RUNTIME_DIR", runtimeDir) - cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) + cfg, err := Load(paths.Layout{ConfigDir: configDir}) if err != nil { t.Fatalf("Load: %v", err) } - if cfg.RuntimeDir != runtimeDir { - t.Fatalf("RuntimeDir = %q, want %q", cfg.RuntimeDir, runtimeDir) + if cfg.FirecrackerBin != firecrackerPath { + t.Fatalf("FirecrackerBin = %q, want %q", cfg.FirecrackerBin, firecrackerPath) } - if cfg.FirecrackerBin != filepath.Join(runtimeDir, meta.FirecrackerBin) { - t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) + wantKey := filepath.Join(configDir, "ssh", "id_ed25519") + if cfg.SSHKeyPath != wantKey { + t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, wantKey) } - if cfg.SSHKeyPath != filepath.Join(runtimeDir, meta.SSHKeyPath) { - t.Fatalf("SSHKeyPath = %q", cfg.SSHKeyPath) + for _, path := range []string{wantKey, wantKey + ".pub"} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("stat %s: %v", path, err) + } } - if cfg.NamegenPath != filepath.Join(runtimeDir, meta.NamegenPath) { - t.Fatalf("NamegenPath = %q", cfg.NamegenPath) + if cfg.DefaultImageName != "default" { + t.Fatalf("DefaultImageName = %q, want default", cfg.DefaultImageName) } - if cfg.CustomizeScript != filepath.Join(runtimeDir, meta.CustomizeScript) { - t.Fatalf("CustomizeScript = %q", cfg.CustomizeScript) - } - if cfg.VSockAgentPath != filepath.Join(runtimeDir, meta.VSockAgentPath) { - t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath) - } - if cfg.DefaultRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) { - t.Fatalf("DefaultRootfs = %q", cfg.DefaultRootfs) - } - if cfg.DefaultWorkSeed != filepath.Join(runtimeDir, meta.DefaultWorkSeed) { - t.Fatalf("DefaultWorkSeed = %q", cfg.DefaultWorkSeed) - } - if cfg.DefaultBaseRootfs != filepath.Join(runtimeDir, meta.DefaultRootfs) { - t.Fatalf("DefaultBaseRootfs = %q", cfg.DefaultBaseRootfs) - } - if cfg.DefaultKernel != filepath.Join(runtimeDir, meta.DefaultKernel) { - t.Fatalf("DefaultKernel = %q", cfg.DefaultKernel) - } - if cfg.DefaultInitrd != filepath.Join(runtimeDir, meta.DefaultInitrd) { - t.Fatalf("DefaultInitrd = %q", cfg.DefaultInitrd) - } - if cfg.DefaultModulesDir != filepath.Join(runtimeDir, meta.DefaultModulesDir) { - t.Fatalf("DefaultModulesDir = %q", cfg.DefaultModulesDir) - } - if cfg.DefaultPackagesFile != filepath.Join(runtimeDir, meta.DefaultPackages) { - t.Fatalf("DefaultPackagesFile = %q", cfg.DefaultPackagesFile) + if cfg.WebListenAddr != "127.0.0.1:7777" { + t.Fatalf("WebListenAddr = %q", cfg.WebListenAddr) } } -func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) { - runtimeDir := t.TempDir() - for _, rel := range []string{ - "firecracker", - "id_ed25519", - "namegen", - "customize.sh", - "banger-vsock-agent", - "packages.apt", - "rootfs-docker.ext4", - "rootfs-docker.work-seed.ext4", - "wtf/root/boot/vmlinux-6.8.0-94-generic", - "wtf/root/boot/initrd.img-6.8.0-94-generic", - "wtf/root/lib/modules/6.8.0-94-generic/modules.dep", - } { - path := filepath.Join(runtimeDir, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) - } - if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } +func TestLoadAppliesConfigOverrides(t *testing.T) { + configDir := t.TempDir() + data := []byte(` +log_level = "debug" +web_listen_addr = "" +firecracker_bin = "/opt/firecracker" +ssh_key_path = "/tmp/custom-key" +default_image_name = "void-exp" +auto_stop_stale_after = "1h" +stats_poll_interval = "15s" +metrics_poll_interval = "30s" +bridge_name = "br-test" +bridge_ip = "10.0.0.1" +cidr = "25" +tap_pool_size = 8 +default_dns = "9.9.9.9" +`) + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil { + t.Fatalf("write config.toml: %v", err) } - t.Setenv("BANGER_RUNTIME_DIR", runtimeDir) - cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if cfg.FirecrackerBin != filepath.Join(runtimeDir, "firecracker") { - t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) - } - if cfg.VSockAgentPath != filepath.Join(runtimeDir, "banger-vsock-agent") { - t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath) - } - if cfg.DefaultWorkSeed != filepath.Join(runtimeDir, "rootfs-docker.work-seed.ext4") { - t.Fatalf("DefaultWorkSeed = %q", cfg.DefaultWorkSeed) - } - if cfg.DefaultKernel != filepath.Join(runtimeDir, "wtf/root/boot/vmlinux-6.8.0-94-generic") { - t.Fatalf("DefaultKernel = %q", cfg.DefaultKernel) - } -} - -func TestLoadAppliesLogLevelEnvOverride(t *testing.T) { - t.Setenv("BANGER_LOG_LEVEL", "debug") - - cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) + cfg, err := Load(paths.Layout{ConfigDir: configDir}) if err != nil { t.Fatalf("Load: %v", err) } @@ -156,158 +72,46 @@ func TestLoadAppliesLogLevelEnvOverride(t *testing.T) { if cfg.LogLevel != "debug" { t.Fatalf("LogLevel = %q", cfg.LogLevel) } -} - -func TestLoadDefaultsLogLevelToInfo(t *testing.T) { - cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.LogLevel != "info" { - t.Fatalf("LogLevel = %q, want info", cfg.LogLevel) - } -} - -func TestLoadIgnoresConfigSSHKeyOverrideForGuestAccess(t *testing.T) { - runtimeDir := t.TempDir() - meta := runtimebundle.BundleMetadata{ - FirecrackerBin: "bin/firecracker", - SSHKeyPath: "keys/id_ed25519", - NamegenPath: "bin/namegen", - CustomizeScript: "scripts/customize.sh", - VSockAgentPath: "bin/banger-vsock-agent", - DefaultPackages: "config/packages.apt", - DefaultRootfs: "images/rootfs.ext4", - DefaultWorkSeed: "images/rootfs.work-seed.ext4", - DefaultKernel: "kernels/vmlinux", - DefaultModulesDir: "modules/current", - } - for _, rel := range []string{ - meta.FirecrackerBin, - meta.SSHKeyPath, - meta.NamegenPath, - meta.CustomizeScript, - meta.VSockAgentPath, - meta.DefaultPackages, - meta.DefaultRootfs, - meta.DefaultWorkSeed, - meta.DefaultKernel, - filepath.Join(meta.DefaultModulesDir, "modules.dep"), - } { - path := filepath.Join(runtimeDir, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) - } - if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } - } - data, err := json.Marshal(meta) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil { - t.Fatalf("write bundle metadata: %v", err) - } - - configDir := t.TempDir() - if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("ssh_key_path = \"/tmp/override-key\"\n"), 0o644); err != nil { - t.Fatalf("write config.toml: %v", err) - } - - t.Setenv("BANGER_RUNTIME_DIR", runtimeDir) - cfg, err := Load(paths.Layout{ConfigDir: configDir}) - if err != nil { - t.Fatalf("Load: %v", err) - } - - want := filepath.Join(runtimeDir, meta.SSHKeyPath) - if cfg.SSHKeyPath != want { - t.Fatalf("SSHKeyPath = %q, want runtime key %q", cfg.SSHKeyPath, want) - } -} - -func TestLoadAcceptsLegacyBundleVsockPingHelperPath(t *testing.T) { - runtimeDir := t.TempDir() - meta := runtimebundle.BundleMetadata{ - FirecrackerBin: "bin/firecracker", - SSHKeyPath: "keys/id_ed25519", - NamegenPath: "bin/namegen", - CustomizeScript: "scripts/customize.sh", - VSockPingHelperPath: "bin/banger-vsock-pingd", - DefaultPackages: "config/packages.apt", - DefaultRootfs: "images/rootfs.ext4", - DefaultKernel: "kernels/vmlinux", - } - for _, rel := range []string{ - meta.FirecrackerBin, - meta.SSHKeyPath, - meta.NamegenPath, - meta.CustomizeScript, - meta.VSockPingHelperPath, - meta.DefaultPackages, - meta.DefaultRootfs, - meta.DefaultKernel, - } { - path := filepath.Join(runtimeDir, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) - } - if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } - } - data, err := json.Marshal(meta) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil { - t.Fatalf("write bundle metadata: %v", err) - } - - t.Setenv("BANGER_RUNTIME_DIR", runtimeDir) - cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.VSockAgentPath != filepath.Join(runtimeDir, meta.VSockPingHelperPath) { - t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath) - } -} - -func TestLoadAcceptsLegacyConfigVsockPingHelperPath(t *testing.T) { - configDir := t.TempDir() - if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("vsock_ping_helper_path = \"/tmp/legacy-agent\"\n"), 0o644); err != nil { - t.Fatalf("write config.toml: %v", err) - } - - cfg, err := Load(paths.Layout{ConfigDir: configDir}) - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.VSockAgentPath != "/tmp/legacy-agent" { - t.Fatalf("VSockAgentPath = %q", cfg.VSockAgentPath) - } -} - -func TestLoadWebListenAddrDefaultsAndAllowsDisable(t *testing.T) { - cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) - if err != nil { - t.Fatalf("Load default config: %v", err) - } - if cfg.WebListenAddr != "127.0.0.1:7777" { - t.Fatalf("WebListenAddr = %q, want default 127.0.0.1:7777", cfg.WebListenAddr) - } - - configDir := t.TempDir() - if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte("web_listen_addr = \"\"\n"), 0o644); err != nil { - t.Fatalf("write config.toml: %v", err) - } - cfg, err = Load(paths.Layout{ConfigDir: configDir}) - if err != nil { - t.Fatalf("Load disabled config: %v", err) - } if cfg.WebListenAddr != "" { - t.Fatalf("WebListenAddr = %q, want disabled empty string", cfg.WebListenAddr) + t.Fatalf("WebListenAddr = %q, want empty", cfg.WebListenAddr) + } + if cfg.FirecrackerBin != "/opt/firecracker" { + t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin) + } + if cfg.SSHKeyPath != "/tmp/custom-key" { + t.Fatalf("SSHKeyPath = %q", cfg.SSHKeyPath) + } + if cfg.DefaultImageName != "void-exp" { + t.Fatalf("DefaultImageName = %q", cfg.DefaultImageName) + } + if cfg.AutoStopStaleAfter != time.Hour { + t.Fatalf("AutoStopStaleAfter = %s", cfg.AutoStopStaleAfter) + } + if cfg.StatsPollInterval != 15*time.Second { + t.Fatalf("StatsPollInterval = %s", cfg.StatsPollInterval) + } + if cfg.MetricsPollInterval != 30*time.Second { + t.Fatalf("MetricsPollInterval = %s", cfg.MetricsPollInterval) + } + if cfg.BridgeName != "br-test" || cfg.BridgeIP != "10.0.0.1" || cfg.CIDR != "25" { + t.Fatalf("bridge config = %+v", cfg) + } + if cfg.TapPoolSize != 8 { + t.Fatalf("TapPoolSize = %d", cfg.TapPoolSize) + } + if cfg.DefaultDNS != "9.9.9.9" { + t.Fatalf("DefaultDNS = %q", cfg.DefaultDNS) + } +} + +func TestLoadAppliesLogLevelEnvOverride(t *testing.T) { + t.Setenv("BANGER_LOG_LEVEL", "warn") + + cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.LogLevel != "warn" { + t.Fatalf("LogLevel = %q, want warn", cfg.LogLevel) } } diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index 0b866bc..d2ec524 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -208,11 +208,13 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model. } func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) { - if strings.TrimSpace(d.config.DefaultWorkSeed) != "" && exists(d.config.DefaultWorkSeed) { - checks := system.NewPreflight() - checks.RequireFile(d.config.DefaultWorkSeed, "default work seed image", `rebuild the default runtime rootfs to regenerate the /root seed`) - report.AddPreflight("feature /root work disk", checks, "seeded /root work disk artifact available") - return + if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" { + if image, err := d.store.GetImageByName(context.Background(), d.config.DefaultImageName); err == nil && strings.TrimSpace(image.WorkSeedPath) != "" && exists(image.WorkSeedPath) { + checks := system.NewPreflight() + checks.RequireFile(image.WorkSeedPath, "default image work-seed", `rebuild the default image to regenerate the /root seed`) + report.AddPreflight("feature /root work disk", checks, "seeded /root work disk artifact available") + return + } } checks := system.NewPreflight() for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 1042caf..e142fd3 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -11,7 +11,6 @@ import ( "net" "net/http" "os" - "path/filepath" "strings" "sync" "time" @@ -85,7 +84,7 @@ func Open(ctx context.Context) (d *Daemon, err error) { closing: make(chan struct{}), pid: os.Getpid(), } - d.logger.Info("daemon opened", "socket", layout.SocketPath, "state_dir", layout.StateDir, "runtime_dir", cfg.RuntimeDir, "log_level", cfg.LogLevel) + d.logger.Info("daemon opened", "socket", layout.SocketPath, "state_dir", layout.StateDir, "log_level", cfg.LogLevel) if err = d.startVMDNS(vmdns.DefaultListenAddr); err != nil { d.logger.Error("daemon open failed", "stage", "start_vm_dns", "error", err.Error()) return nil, err @@ -95,10 +94,6 @@ func Open(ctx context.Context) (d *Daemon, err error) { _ = d.stopVMDNS() } }() - if err = d.ensureDefaultImage(ctx); err != nil { - d.logger.Error("daemon open failed", "stage", "ensure_default_image", "error", err.Error()) - return nil, err - } if err = d.reconcile(ctx); err != nil { d.logger.Error("daemon open failed", "stage", "reconcile", "error", err.Error()) return nil, err @@ -499,95 +494,8 @@ func (d *Daemon) stopVMDNS() error { } func (d *Daemon) ensureDefaultImage(ctx context.Context) error { - if d.config.DefaultImageName == "" { - return nil - } - desired, ok := d.desiredDefaultImage() - if !ok { - if d.logger != nil { - d.logger.Debug("default image skipped", "image_name", d.config.DefaultImageName, "rootfs_path", d.config.DefaultRootfs, "kernel_path", d.config.DefaultKernel) - } - return nil - } - - image, err := d.store.GetImageByName(ctx, d.config.DefaultImageName) - switch { - case err == nil: - if image.Managed { - if d.logger != nil { - d.logger.Debug("managed default image left untouched", append(imageLogAttrs(image), "managed", image.Managed)...) - } - return nil - } - if defaultImageMatches(image, desired) { - if d.logger != nil { - d.logger.Debug("default image already current", imageLogAttrs(image)...) - } - return nil - } - updated := desired - updated.ID = image.ID - updated.CreatedAt = image.CreatedAt - updated.UpdatedAt = model.Now() - if err := d.store.UpsertImage(ctx, updated); err != nil { - return err - } - if d.logger != nil { - d.logger.Info("default image reconciled", append(imageLogAttrs(updated), "previous_rootfs_path", image.RootfsPath, "previous_work_seed_path", image.WorkSeedPath, "previous_kernel_path", image.KernelPath)...) - } - return nil - case errors.Is(err, sql.ErrNoRows): - id, err := model.NewID() - if err != nil { - return err - } - now := model.Now() - desired.ID = id - desired.CreatedAt = now - desired.UpdatedAt = now - if err := d.store.UpsertImage(ctx, desired); err != nil { - return err - } - if d.logger != nil { - d.logger.Info("default image registered", append(imageLogAttrs(desired), "managed", desired.Managed)...) - } - return nil - default: - return err - } -} - -func (d *Daemon) desiredDefaultImage() (model.Image, bool) { - rootfs := d.config.DefaultRootfs - kernel := d.config.DefaultKernel - if !exists(rootfs) || !exists(kernel) { - return model.Image{}, false - } - return model.Image{ - Name: d.config.DefaultImageName, - Managed: false, - ArtifactDir: "", - RootfsPath: rootfs, - WorkSeedPath: d.config.DefaultWorkSeed, - KernelPath: kernel, - InitrdPath: d.config.DefaultInitrd, - ModulesDir: d.config.DefaultModulesDir, - PackagesPath: d.config.DefaultPackagesFile, - Docker: strings.Contains(filepath.Base(rootfs), "docker"), - }, true -} - -func defaultImageMatches(current, desired model.Image) bool { - return current.Name == desired.Name && - current.Managed == desired.Managed && - current.ArtifactDir == desired.ArtifactDir && - current.RootfsPath == desired.RootfsPath && - current.WorkSeedPath == desired.WorkSeedPath && - current.KernelPath == desired.KernelPath && - current.InitrdPath == desired.InitrdPath && - current.ModulesDir == desired.ModulesDir && - current.PackagesPath == desired.PackagesPath && - current.Docker == desired.Docker + _ = ctx + return nil } func (d *Daemon) reconcile(ctx context.Context) error { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 82ffa7f..dc43c59 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -1,722 +1,106 @@ package daemon import ( - "bufio" - "bytes" "context" - "encoding/json" - "net" "os" "path/filepath" "strings" "testing" - "time" "banger/internal/api" "banger/internal/model" "banger/internal/paths" - "banger/internal/rpc" - "banger/internal/store" + "banger/internal/system" ) -func TestEnsureDefaultImageUsesConfiguredDefaultRootfs(t *testing.T) { - dir := t.TempDir() - rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir) - db := openDefaultImageStore(t, dir) - +func TestBuildImageRequiresFromImage(t *testing.T) { d := &Daemon{ - config: model.DaemonConfig{ - DefaultImageName: "default", - DefaultRootfs: rootfs, - DefaultKernel: kernel, - }, - store: db, + layout: paths.Layout{ImagesDir: t.TempDir(), StateDir: t.TempDir()}, + store: openDaemonStore(t), + runner: system.NewRunner(), } - if err := d.ensureDefaultImage(context.Background()); err != nil { - t.Fatalf("ensureDefaultImage: %v", err) - } - - image, err := db.GetImageByName(context.Background(), "default") - if err != nil { - t.Fatalf("GetImageByName: %v", err) - } - if image.RootfsPath != rootfs { - t.Fatalf("RootfsPath = %q, want %q", image.RootfsPath, rootfs) - } - if image.KernelPath != kernel { - t.Fatalf("KernelPath = %q, want %q", image.KernelPath, kernel) - } - if image.Managed { - t.Fatal("default image should be unmanaged") + _, err := d.BuildImage(context.Background(), api.ImageBuildParams{Name: "missing-base"}) + if err == nil || !strings.Contains(err.Error(), "from-image is required") { + t.Fatalf("BuildImage() error = %v", err) } } -func TestEnsureDefaultImageLeavesCurrentUnmanagedDefaultUntouched(t *testing.T) { +func TestRegisterImageRequiresKernel(t *testing.T) { + rootfs := filepath.Join(t.TempDir(), "rootfs.ext4") + if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil { + t.Fatalf("write rootfs: %v", err) + } + d := &Daemon{store: openDaemonStore(t)} + + _, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{ + Name: "missing-kernel", + RootfsPath: rootfs, + }) + if err == nil || !strings.Contains(err.Error(), "kernel path is required") { + t.Fatalf("RegisterImage() error = %v", err) + } +} + +func TestPromoteImageCopiesBootArtifactsIntoArtifactDir(t *testing.T) { dir := t.TempDir() - rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) + rootfs := filepath.Join(dir, "rootfs.ext4") + kernel := filepath.Join(dir, "vmlinux") + initrd := filepath.Join(dir, "initrd.img") + modulesDir := filepath.Join(dir, "modules") + if err := os.MkdirAll(modulesDir, 0o755); err != nil { + t.Fatalf("mkdir modules: %v", err) + } + for path, data := range map[string]string{ + rootfs: "rootfs", + kernel: "kernel", + initrd: "initrd", + filepath.Join(modulesDir, "depmod"): "modules", + } { + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + + db := openDaemonStore(t) image := model.Image{ - ID: "default-id", - Name: "default", - Managed: false, - RootfsPath: rootfs, - KernelPath: kernel, - InitrdPath: initrd, - ModulesDir: modulesDir, - PackagesPath: packages, - Docker: true, - CreatedAt: now, - UpdatedAt: now, + ID: "img-promote", + Name: "void-exp", + Managed: false, + RootfsPath: rootfs, + KernelPath: kernel, + InitrdPath: initrd, + ModulesDir: modulesDir, + CreatedAt: model.Now(), + UpdatedAt: model.Now(), } if err := db.UpsertImage(context.Background(), image); err != nil { t.Fatalf("UpsertImage: %v", err) } - d := &Daemon{ - config: model.DaemonConfig{ - DefaultImageName: "default", - DefaultRootfs: rootfs, - DefaultKernel: kernel, - DefaultInitrd: initrd, - DefaultModulesDir: modulesDir, - DefaultPackagesFile: packages, - }, - store: db, - } - - if err := d.ensureDefaultImage(context.Background()); err != nil { - t.Fatalf("ensureDefaultImage: %v", err) - } - - got, err := db.GetImageByName(context.Background(), "default") - if err != nil { - t.Fatalf("GetImageByName: %v", err) - } - if got.ID != image.ID { - t.Fatalf("ID = %q, want %q", got.ID, image.ID) - } - if !got.UpdatedAt.Equal(image.UpdatedAt) { - t.Fatalf("UpdatedAt = %s, want unchanged %s", got.UpdatedAt, image.UpdatedAt) - } -} - -func TestEnsureDefaultImageReconcilesStaleUnmanagedDefaultInPlace(t *testing.T) { - dir := t.TempDir() - rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) - stale := model.Image{ - ID: "default-id", - Name: "default", - Managed: false, - RootfsPath: "/home/thales/projects/personal/banger/build/runtime/rootfs-docker.ext4", - KernelPath: "/home/thales/projects/personal/banger/build/runtime/wtf/root/boot/vmlinux-6.8.0-94-generic", - InitrdPath: "/home/thales/projects/personal/banger/build/runtime/wtf/root/boot/initrd.img-6.8.0-94-generic", - ModulesDir: "/home/thales/projects/personal/banger/build/runtime/wtf/root/lib/modules/6.8.0-94-generic", - PackagesPath: "/home/thales/projects/personal/banger/build/runtime/packages.apt", - Docker: true, - CreatedAt: now, - UpdatedAt: now, - } - if err := db.UpsertImage(context.Background(), stale); err != nil { - t.Fatalf("UpsertImage: %v", err) - } - vm := testVM("uses-default", stale.ID, "172.16.0.25") - if err := db.UpsertVM(context.Background(), vm); err != nil { - t.Fatalf("UpsertVM: %v", err) + imagesDir := filepath.Join(dir, "images") + if err := os.MkdirAll(imagesDir, 0o755); err != nil { + t.Fatalf("mkdir images dir: %v", err) } d := &Daemon{ - config: model.DaemonConfig{ - DefaultImageName: "default", - DefaultRootfs: rootfs, - DefaultKernel: kernel, - DefaultInitrd: initrd, - DefaultModulesDir: modulesDir, - DefaultPackagesFile: packages, - }, - store: db, - } - - if err := d.ensureDefaultImage(context.Background()); err != nil { - t.Fatalf("ensureDefaultImage: %v", err) - } - - got, err := db.GetImageByName(context.Background(), "default") - if err != nil { - t.Fatalf("GetImageByName: %v", err) - } - if got.ID != stale.ID { - t.Fatalf("ID = %q, want preserved %q", got.ID, stale.ID) - } - if !got.CreatedAt.Equal(stale.CreatedAt) { - t.Fatalf("CreatedAt = %s, want preserved %s", got.CreatedAt, stale.CreatedAt) - } - if got.RootfsPath != rootfs || got.KernelPath != kernel || got.InitrdPath != initrd || got.ModulesDir != modulesDir || got.PackagesPath != packages { - t.Fatalf("stale default not reconciled: %+v", got) - } - if !got.UpdatedAt.After(stale.UpdatedAt) { - t.Fatalf("UpdatedAt = %s, want newer than %s", got.UpdatedAt, stale.UpdatedAt) - } - gotVM, err := db.GetVMByID(context.Background(), vm.ID) - if err != nil { - t.Fatalf("GetVMByID: %v", err) - } - if gotVM.ImageID != stale.ID { - t.Fatalf("VM image ID = %q, want preserved %q", gotVM.ImageID, stale.ID) - } -} - -func TestEnsureDefaultImageLeavesManagedDefaultUntouched(t *testing.T) { - dir := t.TempDir() - rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir) - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) - managed := model.Image{ - ID: "managed-default", - Name: "default", - Managed: true, - RootfsPath: "/managed/rootfs.ext4", - KernelPath: "/managed/vmlinux", - CreatedAt: now, - UpdatedAt: now, - } - if err := db.UpsertImage(context.Background(), managed); err != nil { - t.Fatalf("UpsertImage: %v", err) - } - - d := &Daemon{ - config: model.DaemonConfig{ - DefaultImageName: "default", - DefaultRootfs: rootfs, - DefaultKernel: kernel, - }, - store: db, - } - - if err := d.ensureDefaultImage(context.Background()); err != nil { - t.Fatalf("ensureDefaultImage: %v", err) - } - - got, err := db.GetImageByName(context.Background(), "default") - if err != nil { - t.Fatalf("GetImageByName: %v", err) - } - if got.RootfsPath != managed.RootfsPath || got.KernelPath != managed.KernelPath { - t.Fatalf("managed default was rewritten: %+v", got) - } -} - -func TestEnsureDefaultImageSkipsRewriteWhenCurrentArtifactsMissing(t *testing.T) { - dir := t.TempDir() - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) - stale := model.Image{ - ID: "default-id", - Name: "default", - Managed: false, - RootfsPath: "/old/rootfs.ext4", - KernelPath: "/old/vmlinux", - CreatedAt: now, - UpdatedAt: now, - } - if err := db.UpsertImage(context.Background(), stale); err != nil { - t.Fatalf("UpsertImage: %v", err) - } - - d := &Daemon{ - config: model.DaemonConfig{ - DefaultImageName: "default", - DefaultRootfs: filepath.Join(dir, "missing-rootfs.ext4"), - DefaultKernel: filepath.Join(dir, "missing-vmlinux"), - }, - store: db, - } - - if err := d.ensureDefaultImage(context.Background()); err != nil { - t.Fatalf("ensureDefaultImage: %v", err) - } - - got, err := db.GetImageByName(context.Background(), "default") - if err != nil { - t.Fatalf("GetImageByName: %v", err) - } - if got.RootfsPath != stale.RootfsPath || got.KernelPath != stale.KernelPath { - t.Fatalf("default image should have stayed stale when no current artifacts exist: %+v", got) - } -} - -func TestRegisterImageCreatesUnmanagedImage(t *testing.T) { - dir := t.TempDir() - rootfs, kernel, initrd, modulesDir, _ := writeDefaultImageArtifacts(t, dir) - workSeed := filepath.Join(dir, "rootfs-void.work-seed.ext4") - packages := filepath.Join(dir, "packages.void") - if err := os.WriteFile(workSeed, []byte("seed"), 0o644); err != nil { - t.Fatalf("WriteFile(workSeed): %v", err) - } - if err := os.WriteFile(packages, []byte("base-minimal\nopenssh\n"), 0o644); err != nil { - t.Fatalf("WriteFile(packages): %v", err) - } - db := openDefaultImageStore(t, dir) - d := &Daemon{ - config: model.DaemonConfig{ - DefaultKernel: kernel, - DefaultInitrd: initrd, - DefaultModulesDir: modulesDir, - }, - store: db, - } - - image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{ - Name: "void-exp", - RootfsPath: rootfs, - WorkSeedPath: workSeed, - PackagesPath: packages, - }) - if err != nil { - t.Fatalf("RegisterImage: %v", err) - } - if image.Managed { - t.Fatal("registered image should be unmanaged") - } - if image.Name != "void-exp" || image.RootfsPath != rootfs || image.WorkSeedPath != workSeed || image.KernelPath != kernel { - t.Fatalf("registered image = %+v", image) - } -} - -func TestRegisterImageUpdatesExistingUnmanagedImageInPlace(t *testing.T) { - dir := t.TempDir() - _, kernel, initrd, modulesDir, _ := writeDefaultImageArtifacts(t, dir) - newRootfs := filepath.Join(dir, "rootfs-void-next.ext4") - newWorkSeed := filepath.Join(dir, "rootfs-void-next.work-seed.ext4") - packages := filepath.Join(dir, "packages.void") - for _, path := range []string{newRootfs, newWorkSeed} { - if err := os.WriteFile(path, []byte("next"), 0o644); err != nil { - t.Fatalf("WriteFile(%s): %v", path, err) - } - } - if err := os.WriteFile(packages, []byte("base-minimal\n"), 0o644); err != nil { - t.Fatalf("WriteFile(packages): %v", err) - } - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) - existing := model.Image{ - ID: "void-image-id", - Name: "void-exp", - Managed: false, - RootfsPath: filepath.Join(dir, "old-rootfs.ext4"), - KernelPath: kernel, - InitrdPath: initrd, - ModulesDir: modulesDir, - PackagesPath: packages, - CreatedAt: now, - UpdatedAt: now, - } - if err := db.UpsertImage(context.Background(), existing); err != nil { - t.Fatalf("UpsertImage: %v", err) - } - d := &Daemon{ - config: model.DaemonConfig{ - DefaultKernel: kernel, - DefaultInitrd: initrd, - DefaultModulesDir: modulesDir, - }, - store: db, - } - - image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{ - Name: "void-exp", - RootfsPath: newRootfs, - WorkSeedPath: newWorkSeed, - PackagesPath: packages, - }) - if err != nil { - t.Fatalf("RegisterImage: %v", err) - } - if image.ID != existing.ID || !image.CreatedAt.Equal(existing.CreatedAt) { - t.Fatalf("updated image identity changed: %+v", image) - } - if image.RootfsPath != newRootfs || image.WorkSeedPath != newWorkSeed { - t.Fatalf("updated image paths not applied: %+v", image) - } -} - -func TestRegisterImageRejectsManagedOverwrite(t *testing.T) { - dir := t.TempDir() - rootfs, kernel, _, _, _ := writeDefaultImageArtifacts(t, dir) - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) - if err := db.UpsertImage(context.Background(), model.Image{ - ID: "managed-id", - Name: "void-exp", - Managed: true, - RootfsPath: rootfs, - KernelPath: kernel, - CreatedAt: now, - UpdatedAt: now, - }); err != nil { - t.Fatalf("UpsertImage: %v", err) - } - d := &Daemon{config: model.DaemonConfig{DefaultKernel: kernel}, store: db} - - _, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{ - Name: "void-exp", - RootfsPath: rootfs, - }) - if err == nil || !strings.Contains(err.Error(), "cannot be updated via register") { - t.Fatalf("RegisterImage(managed) error = %v", err) - } -} - -func TestPromoteImageCopiesArtifactsAndPreservesIdentity(t *testing.T) { - dir := t.TempDir() - rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) - workSeed := filepath.Join(dir, "rootfs-docker.work-seed.ext4") - workSeedContent := []byte("seed-data") - if err := os.WriteFile(workSeed, workSeedContent, 0o644); err != nil { - t.Fatalf("WriteFile(workSeed): %v", err) - } - - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) - existing := model.Image{ - ID: "promote-image-id", - Name: "default", - Managed: false, - RootfsPath: rootfs, - WorkSeedPath: workSeed, - KernelPath: kernel, - InitrdPath: initrd, - ModulesDir: modulesDir, - PackagesPath: packages, - Docker: true, - CreatedAt: now, - UpdatedAt: now, - } - if err := db.UpsertImage(context.Background(), existing); err != nil { - t.Fatalf("UpsertImage: %v", err) - } - vm := testVM("uses-default", existing.ID, "172.16.0.44") - if err := db.UpsertVM(context.Background(), vm); err != nil { - t.Fatalf("UpsertVM: %v", err) - } - - d := &Daemon{ - layout: modelPathsLayoutForTest(dir), + layout: paths.Layout{ImagesDir: imagesDir}, store: db, + runner: system.NewRunner(), } - - image, err := d.PromoteImage(context.Background(), "default") + got, err := d.PromoteImage(context.Background(), image.Name) if err != nil { t.Fatalf("PromoteImage: %v", err) } - if !image.Managed { + if !got.Managed { t.Fatal("promoted image should be managed") } - if image.ID != existing.ID || image.Name != existing.Name { - t.Fatalf("promoted image identity changed: %+v", image) - } - if !image.CreatedAt.Equal(existing.CreatedAt) { - t.Fatalf("CreatedAt = %s, want preserved %s", image.CreatedAt, existing.CreatedAt) - } - if !image.UpdatedAt.After(existing.UpdatedAt) { - t.Fatalf("UpdatedAt = %s, want newer than %s", image.UpdatedAt, existing.UpdatedAt) - } - wantArtifactDir := filepath.Join(d.layout.ImagesDir, existing.ID) - if image.ArtifactDir != wantArtifactDir { - t.Fatalf("ArtifactDir = %q, want %q", image.ArtifactDir, wantArtifactDir) - } - if image.RootfsPath != filepath.Join(wantArtifactDir, "rootfs.ext4") { - t.Fatalf("RootfsPath = %q, want managed copy", image.RootfsPath) - } - if image.WorkSeedPath != filepath.Join(wantArtifactDir, "work-seed.ext4") { - t.Fatalf("WorkSeedPath = %q, want managed copy", image.WorkSeedPath) - } - if image.KernelPath != kernel || image.InitrdPath != initrd || image.ModulesDir != modulesDir || image.PackagesPath != packages { - t.Fatalf("boot support paths changed unexpectedly: %+v", image) - } - - rootfsContent, err := os.ReadFile(rootfs) - if err != nil { - t.Fatalf("ReadFile(rootfs): %v", err) - } - managedRootfsContent, err := os.ReadFile(image.RootfsPath) - if err != nil { - t.Fatalf("ReadFile(managed rootfs): %v", err) - } - if !bytes.Equal(managedRootfsContent, rootfsContent) { - t.Fatal("managed rootfs copy content mismatch") - } - managedWorkSeedContent, err := os.ReadFile(image.WorkSeedPath) - if err != nil { - t.Fatalf("ReadFile(managed work seed): %v", err) - } - if !bytes.Equal(managedWorkSeedContent, workSeedContent) { - t.Fatal("managed work seed copy content mismatch") - } - - got, err := db.GetImageByName(context.Background(), "default") - if err != nil { - t.Fatalf("GetImageByName: %v", err) - } - if got.RootfsPath != image.RootfsPath || !got.Managed || got.ArtifactDir != image.ArtifactDir { - t.Fatalf("stored promoted image = %+v, want %+v", got, image) - } - gotVM, err := db.GetVMByID(context.Background(), vm.ID) - if err != nil { - t.Fatalf("GetVMByID: %v", err) - } - if gotVM.ImageID != existing.ID { - t.Fatalf("VM image ID = %q, want preserved %q", gotVM.ImageID, existing.ID) - } -} - -func TestPromoteImageRejectsManagedImage(t *testing.T) { - dir := t.TempDir() - rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) - if err := db.UpsertImage(context.Background(), model.Image{ - ID: "managed-id", - Name: "default", - Managed: true, - ArtifactDir: filepath.Join(dir, "images", "managed-id"), - RootfsPath: rootfs, - KernelPath: kernel, - InitrdPath: initrd, - ModulesDir: modulesDir, - PackagesPath: packages, - CreatedAt: now, - UpdatedAt: now, - }); err != nil { - t.Fatalf("UpsertImage: %v", err) - } - d := &Daemon{ - layout: modelPathsLayoutForTest(dir), - store: db, - } - - _, err := d.PromoteImage(context.Background(), "default") - if err == nil || !strings.Contains(err.Error(), "already managed") { - t.Fatalf("PromoteImage(managed) error = %v", err) - } -} - -func TestPromoteImageSkipsMissingWorkSeed(t *testing.T) { - dir := t.TempDir() - rootfs, kernel, initrd, modulesDir, packages := writeDefaultImageArtifacts(t, dir) - db := openDefaultImageStore(t, dir) - now := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) - existing := model.Image{ - ID: "promote-missing-seed", - Name: "default", - Managed: false, - RootfsPath: rootfs, - WorkSeedPath: filepath.Join(dir, "missing.work-seed.ext4"), - KernelPath: kernel, - InitrdPath: initrd, - ModulesDir: modulesDir, - PackagesPath: packages, - CreatedAt: now, - UpdatedAt: now, - } - if err := db.UpsertImage(context.Background(), existing); err != nil { - t.Fatalf("UpsertImage: %v", err) - } - d := &Daemon{ - layout: modelPathsLayoutForTest(dir), - store: db, - } - - image, err := d.PromoteImage(context.Background(), "default") - if err != nil { - t.Fatalf("PromoteImage: %v", err) - } - if image.WorkSeedPath != "" { - t.Fatalf("WorkSeedPath = %q, want empty for missing source work seed", image.WorkSeedPath) - } - if _, err := os.Stat(filepath.Join(image.ArtifactDir, "work-seed.ext4")); !os.IsNotExist(err) { - t.Fatalf("managed work-seed should not exist, stat error = %v", err) - } -} - -func openDefaultImageStore(t *testing.T, dir string) *store.Store { - t.Helper() - db, err := store.Open(filepath.Join(dir, "state.db")) - if err != nil { - t.Fatalf("open store: %v", err) - } - t.Cleanup(func() { - _ = db.Close() - }) - return db -} - -func writeDefaultImageArtifacts(t *testing.T, dir string) (rootfs, kernel, initrd, modulesDir, packages string) { - t.Helper() - rootfs = filepath.Join(dir, "rootfs-docker.ext4") - kernel = filepath.Join(dir, "vmlinux") - initrd = filepath.Join(dir, "initrd.img") - modulesDir = filepath.Join(dir, "modules") - packages = filepath.Join(dir, "packages.apt") - files := []string{ - rootfs, - kernel, - initrd, - packages, - filepath.Join(modulesDir, "modules.dep"), - } - for _, path := range files { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + for _, path := range []string{got.RootfsPath, got.KernelPath, got.InitrdPath, got.ModulesDir} { + if !strings.HasPrefix(path, got.ArtifactDir) { + t.Fatalf("artifact path %q does not live under %q", path, got.ArtifactDir) } - if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) + if _, err := os.Stat(path); err != nil { + t.Fatalf("stat %s: %v", path, err) } } - return rootfs, kernel, initrd, modulesDir, packages -} - -func modelPathsLayoutForTest(dir string) paths.Layout { - return paths.Layout{ - ImagesDir: filepath.Join(dir, "images"), - } -} - -func TestStartVMDNSFailsWhenAddressBusy(t *testing.T) { - t.Parallel() - - packetConn, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatalf("ListenPacket: %v", err) - } - defer packetConn.Close() - - d := &Daemon{} - if err := d.startVMDNS(packetConn.LocalAddr().String()); err == nil { - t.Fatal("startVMDNS() succeeded on occupied address, want failure") - } -} - -func TestSetDNSPublishesIntoDaemonServer(t *testing.T) { - t.Parallel() - - d := &Daemon{} - if err := d.startVMDNS("127.0.0.1:0"); err != nil { - t.Fatalf("startVMDNS: %v", err) - } - defer d.stopVMDNS() - - if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil { - t.Fatalf("setDNS: %v", err) - } - if _, ok := d.vmDNS.Lookup("devbox.vm"); !ok { - t.Fatal("devbox.vm missing after setDNS") - } -} - -func TestDispatchUsesPassedContext(t *testing.T) { - t.Parallel() - - db := openDefaultImageStore(t, t.TempDir()) - d := &Daemon{store: db} - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - resp := d.dispatch(ctx, rpc.Request{ - Version: rpc.Version, - Method: "vm.list", - Params: mustJSON(t, api.Empty{}), - }) - - if resp.OK { - t.Fatal("dispatch() succeeded with canceled context") - } - if resp.Error == nil || !strings.Contains(resp.Error.Message, context.Canceled.Error()) { - t.Fatalf("dispatch() error = %+v, want context canceled", resp.Error) - } -} - -func TestHandleConnCancelsRequestWhenClientDisconnects(t *testing.T) { - t.Parallel() - - server, client := net.Pipe() - defer client.Close() - - requestCanceled := make(chan struct{}) - done := make(chan struct{}) - d := &Daemon{ - closing: make(chan struct{}), - requestHandler: func(ctx context.Context, req rpc.Request) rpc.Response { - if req.Method != "block" { - t.Errorf("request method = %q, want block", req.Method) - } - <-ctx.Done() - close(requestCanceled) - return rpc.NewError("operation_failed", ctx.Err().Error()) - }, - } - - go func() { - d.handleConn(server) - close(done) - }() - - if err := json.NewEncoder(client).Encode(rpc.Request{Version: rpc.Version, Method: "block"}); err != nil { - t.Fatalf("encode request: %v", err) - } - if err := client.Close(); err != nil { - t.Fatalf("close client: %v", err) - } - - select { - case <-requestCanceled: - case <-time.After(2 * time.Second): - t.Fatal("request context was not canceled after client disconnect") - } - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("handleConn did not return after client disconnect") - } -} - -func TestWatchRequestDisconnectCancelsContextOnEOF(t *testing.T) { - t.Parallel() - - server, client := net.Pipe() - defer server.Close() - - reader := bufio.NewReader(server) - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - d := &Daemon{closing: make(chan struct{})} - stop := d.watchRequestDisconnect(server, reader, "block", cancel) - defer stop() - - if err := client.Close(); err != nil { - t.Fatalf("close client: %v", err) - } - - select { - case <-ctx.Done(): - if !strings.Contains(ctx.Err().Error(), context.Canceled.Error()) { - t.Fatalf("ctx.Err() = %v, want canceled", ctx.Err()) - } - case <-time.After(2 * time.Second): - t.Fatal("watchRequestDisconnect did not cancel context") - } -} - -func mustJSON(t *testing.T, v any) []byte { - t.Helper() - data, err := json.Marshal(v) - if err != nil { - t.Fatalf("json.Marshal(%T): %v", v, err) - } - return data } diff --git a/internal/daemon/doctor.go b/internal/daemon/doctor.go index e4c9802..b29c312 100644 --- a/internal/daemon/doctor.go +++ b/internal/daemon/doctor.go @@ -2,12 +2,13 @@ package daemon import ( "context" - "fmt" + "database/sql" "strings" "banger/internal/config" "banger/internal/model" "banger/internal/paths" + "banger/internal/store" "banger/internal/system" ) @@ -25,34 +26,49 @@ func Doctor(ctx context.Context) (system.Report, error) { config: cfg, runner: system.NewRunner(), } + db, err := store.Open(layout.DBPath) + if err == nil { + defer db.Close() + d.store = db + } return d.doctorReport(ctx), nil } func (d *Daemon) doctorReport(ctx context.Context) system.Report { report := system.Report{} - report.AddPreflight("runtime bundle", d.runtimeBundleChecks(), runtimeBundleStatus(d.config)) + report.AddPreflight("host runtime", d.runtimeChecks(), runtimeStatus(d.config)) report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available") - report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock agent prerequisites available") + report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available") d.addCapabilityDoctorChecks(ctx, &report) report.AddPreflight("image build", d.imageBuildChecks(ctx), "image build prerequisites available") return report } -func (d *Daemon) runtimeBundleChecks() *system.Preflight { +func (d *Daemon) runtimeChecks() *system.Preflight { checks := system.NewPreflight() - hint := paths.RuntimeBundleHint() - checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) - checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`) - checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`) - checks.RequireFile(d.config.DefaultRootfs, "default rootfs image", `set "default_rootfs" or refresh the runtime bundle`) - checks.RequireFile(d.config.DefaultKernel, "kernel image", `set "default_kernel" or refresh the runtime bundle`) - if strings.TrimSpace(d.config.DefaultInitrd) != "" { - checks.RequireFile(d.config.DefaultInitrd, "initrd image", `set "default_initrd" or refresh the runtime bundle`) + checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`) + checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) + if helper, err := d.vsockAgentBinary(); err == nil { + checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`) + } else { + checks.Addf("%v", err) } - if strings.TrimSpace(d.config.DefaultPackagesFile) != "" { - checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`) + if d.store != nil && strings.TrimSpace(d.config.DefaultImageName) != "" { + image, err := d.store.GetImageByName(context.Background(), d.config.DefaultImageName) + switch { + case err == nil: + checks.RequireFile(image.RootfsPath, "default image rootfs", `re-register or rebuild the default image`) + checks.RequireFile(image.KernelPath, "default image kernel", `re-register or rebuild the default image`) + if strings.TrimSpace(image.InitrdPath) != "" { + checks.RequireFile(image.InitrdPath, "default image initrd", `re-register or rebuild the default image`) + } + case err != nil && err != sql.ErrNoRows: + checks.Addf("failed to inspect default image %q: %v", d.config.DefaultImageName, err) + default: + checks.Addf("default image %q is not registered", d.config.DefaultImageName) + } } return checks } @@ -65,37 +81,33 @@ func (d *Daemon) coreVMLifecycleChecks() *system.Preflight { func (d *Daemon) imageBuildChecks(ctx context.Context) *system.Preflight { checks := system.NewPreflight() - d.addImageBuildPrereqs( - ctx, - checks, - firstNonEmpty(d.config.DefaultBaseRootfs, d.config.DefaultRootfs), - d.config.DefaultKernel, - d.config.DefaultInitrd, - d.config.DefaultModulesDir, - "", - ) + if d.store == nil || strings.TrimSpace(d.config.DefaultImageName) == "" { + checks.Addf("default image is not available for build inheritance") + return checks + } + image, err := d.store.GetImageByName(ctx, d.config.DefaultImageName) + if err != nil { + checks.Addf("default image %q is not registered", d.config.DefaultImageName) + return checks + } + d.addImageBuildPrereqs(ctx, checks, image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir, "") return checks } func (d *Daemon) vsockChecks() *system.Preflight { checks := system.NewPreflight() - checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`) + if helper, err := d.vsockAgentBinary(); err == nil { + checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`) + } else { + checks.Addf("%v", err) + } checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host") return checks } -func runtimeBundleStatus(cfg model.DaemonConfig) string { - if strings.TrimSpace(cfg.RuntimeDir) == "" { - return "runtime dir not configured" +func runtimeStatus(cfg model.DaemonConfig) string { + if strings.TrimSpace(cfg.FirecrackerBin) == "" { + return "firecracker not configured" } - return fmt.Sprintf("runtime dir %s", cfg.RuntimeDir) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" + return "firecracker and ssh key resolved" } diff --git a/internal/daemon/imagebuild.go b/internal/daemon/imagebuild.go index bccf6f3..f22f040 100644 --- a/internal/daemon/imagebuild.go +++ b/internal/daemon/imagebuild.go @@ -3,7 +3,6 @@ package daemon import ( "bytes" "context" - "crypto/sha256" "errors" "fmt" "io" @@ -16,6 +15,7 @@ import ( "banger/internal/guest" "banger/internal/guestnet" "banger/internal/hostnat" + "banger/internal/imagepreset" "banger/internal/model" "banger/internal/opencode" "banger/internal/system" @@ -39,13 +39,13 @@ const ( type imageBuildSpec struct { ID string Name string - BaseRootfs string + SourceRootfs string RootfsPath string BuildLog io.Writer KernelPath string InitrdPath string ModulesDir string - PackagesPath string + Packages []string InstallDocker bool Size string } @@ -66,15 +66,11 @@ func (d *Daemon) runImageBuild(ctx context.Context, spec imageBuildSpec) error { } func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) (err error) { - packages, err := system.ReadNormalizedLines(spec.PackagesPath) - if err != nil { - return err - } - if err := system.CopyFilePreferClone(spec.BaseRootfs, spec.RootfsPath); err != nil { + if err := system.CopyFilePreferClone(spec.SourceRootfs, spec.RootfsPath); err != nil { return err } if spec.Size != "" { - if err := resizeRootfs(spec.BaseRootfs, spec.RootfsPath, spec.Size); err != nil { + if err := resizeRootfs(spec.SourceRootfs, spec.RootfsPath, spec.Size); err != nil { return err } } @@ -110,7 +106,11 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) ( return err } - helperBytes, err := os.ReadFile(d.config.VSockAgentPath) + vsockAgentPath, err := d.vsockAgentBinary() + if err != nil { + return err + } + helperBytes, err := os.ReadFile(vsockAgentPath) if err != nil { return err } @@ -123,7 +123,7 @@ func (d *Daemon) runImageBuildNative(ctx context.Context, spec imageBuildSpec) ( if err := writeBuildLog(spec.BuildLog, "configuring guest"); err != nil { return err } - if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), packages, spec.InstallDocker), spec.BuildLog); err != nil { + if err := client.RunScript(ctx, buildProvisionScript(vm.Name, d.config.DefaultDNS, string(authorizedKey), spec.Packages, spec.InstallDocker), spec.BuildLog); err != nil { return err } if strings.TrimSpace(spec.ModulesDir) != "" { @@ -428,6 +428,5 @@ func writeBuildLog(w io.Writer, message string) error { } func packagesHash(lines []string) string { - sum := sha256.Sum256([]byte(strings.Join(lines, "\n") + "\n")) - return fmt.Sprintf("%x", sum) + return imagepreset.Hash(lines) } diff --git a/internal/daemon/images.go b/internal/daemon/images.go index 365e53d..b20873e 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -10,8 +10,8 @@ import ( "strings" "banger/internal/api" + "banger/internal/imagepreset" "banger/internal/model" - "banger/internal/paths" "banger/internal/system" ) @@ -37,12 +37,13 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i if _, err := d.FindImage(ctx, name); err == nil { return model.Image{}, fmt.Errorf("image name already exists: %s", name) } - baseRootfs := params.BaseRootfs - if baseRootfs == "" { - baseRootfs = d.config.DefaultBaseRootfs + fromImage := strings.TrimSpace(params.FromImage) + if fromImage == "" { + return model.Image{}, fmt.Errorf("from-image is required") } - if baseRootfs == "" { - return model.Image{}, fmt.Errorf("base rootfs is required; %s", paths.RuntimeBundleHint()) + baseImage, err := d.FindImage(ctx, fromImage) + if err != nil { + return model.Image{}, err } id, err := model.NewID() if err != nil { @@ -50,9 +51,6 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i } now := model.Now() artifactDir := filepath.Join(d.layout.ImagesDir, id) - if err := os.MkdirAll(artifactDir, 0o755); err != nil { - return model.Image{}, err - } buildLogDir := filepath.Join(d.layout.StateDir, "image-build") if err := os.MkdirAll(buildLogDir, 0o755); err != nil { return model.Image{}, err @@ -64,73 +62,80 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i return model.Image{}, err } defer logFile.Close() - rootfsPath := filepath.Join(artifactDir, "rootfs.ext4") - workSeedPath := filepath.Join(artifactDir, "work-seed.ext4") - kernelPath := params.KernelPath - if kernelPath == "" { - kernelPath = d.config.DefaultKernel - } - initrdPath := params.InitrdPath - if initrdPath == "" { - initrdPath = d.config.DefaultInitrd - } - modulesDir := params.ModulesDir - if modulesDir == "" { - modulesDir = d.config.DefaultModulesDir - } - if err := d.validateImageBuildPrereqs(ctx, baseRootfs, kernelPath, initrdPath, modulesDir, params.Size); err != nil { + stageDir, err := os.MkdirTemp(d.layout.ImagesDir, id+".build-") + if err != nil { return model.Image{}, err } + cleanupStage := true + defer func() { + if cleanupStage { + _ = os.RemoveAll(stageDir) + } + }() + rootfsPath := filepath.Join(stageDir, "rootfs.ext4") + workSeedPath := filepath.Join(stageDir, "work-seed.ext4") + kernelSource := firstNonEmpty(params.KernelPath, baseImage.KernelPath) + initrdSource := firstNonEmpty(params.InitrdPath, baseImage.InitrdPath) + modulesSource := firstNonEmpty(params.ModulesDir, baseImage.ModulesDir) + if err := d.validateImageBuildPrereqs(ctx, baseImage.RootfsPath, kernelSource, initrdSource, modulesSource, params.Size); err != nil { + return model.Image{}, err + } + kernelPath, initrdPath, modulesDir, err := stageManagedBootArtifacts(ctx, d.runner, stageDir, kernelSource, initrdSource, modulesSource) + if err != nil { + return model.Image{}, err + } + packages := imagepreset.DebianBasePackages() + metadataPackages := imageBuildMetadataPackages(params.Docker) spec := imageBuildSpec{ ID: id, Name: name, - BaseRootfs: baseRootfs, + SourceRootfs: baseImage.RootfsPath, RootfsPath: rootfsPath, BuildLog: logFile, KernelPath: kernelPath, InitrdPath: initrdPath, ModulesDir: modulesDir, - PackagesPath: d.config.DefaultPackagesFile, + Packages: packages, InstallDocker: params.Docker, Size: params.Size, } - op.stage("launch_builder", "build_log_path", buildLogPath, "artifact_dir", artifactDir) + op.stage("launch_builder", "build_log_path", buildLogPath, "artifact_dir", artifactDir, "from_image", baseImage.Name) imageBuildStage(ctx, "launch_builder", "building rootfs from base image") if err := d.runImageBuild(ctx, spec); err != nil { _ = logFile.Sync() - _ = os.RemoveAll(artifactDir) return model.Image{}, err } imageBuildStage(ctx, "prepare_work_seed", "building reusable work seed") if err := system.BuildWorkSeedImage(ctx, d.runner, rootfsPath, workSeedPath); err != nil { _ = logFile.Sync() - _ = os.RemoveAll(artifactDir) return model.Image{}, err } imageBuildStage(ctx, "seed_ssh", "seeding runtime SSH access") seededSSHPublicKeyFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) if err != nil { _ = logFile.Sync() - _ = os.RemoveAll(artifactDir) return model.Image{}, err } imageBuildStage(ctx, "write_metadata", "writing image metadata") - if err := writePackagesMetadata(rootfsPath, d.config.DefaultPackagesFile); err != nil { + if err := writePackagesMetadata(rootfsPath, metadataPackages); err != nil { _ = logFile.Sync() - _ = os.RemoveAll(artifactDir) return model.Image{}, err } + op.stage("activate_artifacts", "artifact_dir", artifactDir) + if err := os.Rename(stageDir, artifactDir); err != nil { + return model.Image{}, err + } + cleanupStage = false image = model.Image{ ID: id, Name: name, Managed: true, ArtifactDir: artifactDir, - RootfsPath: rootfsPath, - WorkSeedPath: workSeedPath, - KernelPath: kernelPath, - InitrdPath: initrdPath, - ModulesDir: modulesDir, - PackagesPath: d.config.DefaultPackagesFile, + RootfsPath: filepath.Join(artifactDir, "rootfs.ext4"), + WorkSeedPath: filepath.Join(artifactDir, "work-seed.ext4"), + KernelPath: filepath.Join(artifactDir, "kernel"), + InitrdPath: stageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img"), + ModulesDir: stageOptionalArtifactPath(artifactDir, modulesDir, "modules"), BuildSize: params.Size, SeededSSHPublicKeyFingerprint: seededSSHPublicKeyFingerprint, Docker: params.Docker, @@ -174,19 +179,12 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara } kernelPath := strings.TrimSpace(params.KernelPath) if kernelPath == "" { - kernelPath = d.config.DefaultKernel + return model.Image{}, fmt.Errorf("kernel path is required") } initrdPath := strings.TrimSpace(params.InitrdPath) - if initrdPath == "" { - initrdPath = d.config.DefaultInitrd - } modulesDir := strings.TrimSpace(params.ModulesDir) - if modulesDir == "" { - modulesDir = d.config.DefaultModulesDir - } - packagesPath := strings.TrimSpace(params.PackagesPath) - if err := validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath); err != nil { + if err := validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir); err != nil { return model.Image{}, err } @@ -203,7 +201,6 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara image.KernelPath = kernelPath image.InitrdPath = initrdPath image.ModulesDir = modulesDir - image.PackagesPath = packagesPath image.Docker = params.Docker image.UpdatedAt = now case errors.Is(lookupErr, sql.ErrNoRows): @@ -220,7 +217,6 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara KernelPath: kernelPath, InitrdPath: initrdPath, ModulesDir: modulesDir, - PackagesPath: packagesPath, Docker: params.Docker, CreatedAt: now, UpdatedAt: now, @@ -255,7 +251,7 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model if image.Managed { return model.Image{}, fmt.Errorf("image %s is already managed", image.Name) } - if err := validateImagePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir, image.PackagesPath); err != nil { + if err := validateImagePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir); err != nil { return model.Image{}, err } if strings.TrimSpace(d.layout.ImagesDir) == "" { @@ -313,6 +309,10 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model } else { image.SeededSSHPublicKeyFingerprint = "" } + _, initrdPath, modulesDir, err := stageManagedBootArtifacts(ctx, d.runner, stageDir, image.KernelPath, image.InitrdPath, image.ModulesDir) + if err != nil { + return model.Image{}, err + } op.stage("activate_artifacts", "artifact_dir", artifactDir) if err := os.Rename(stageDir, artifactDir); err != nil { @@ -326,6 +326,9 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model if workSeedPath != "" { image.WorkSeedPath = filepath.Join(artifactDir, "work-seed.ext4") } + image.KernelPath = filepath.Join(artifactDir, "kernel") + image.InitrdPath = stageOptionalArtifactPath(artifactDir, initrdPath, "initrd.img") + image.ModulesDir = stageOptionalArtifactPath(artifactDir, modulesDir, "modules") image.UpdatedAt = model.Now() if err := d.store.UpsertImage(ctx, image); err != nil { _ = os.RemoveAll(artifactDir) @@ -334,26 +337,23 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model return image, nil } -func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir, packagesPath string) error { +func validateImageRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir string) error { checks := system.NewPreflight() checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs `) - checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`) + checks.RequireFile(kernelPath, "kernel image", `pass --kernel `) if workSeedPath != "" { checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed or rebuild the image with a work seed`) } if initrdPath != "" { - checks.RequireFile(initrdPath, "initrd image", `pass --initrd or set "default_initrd"`) + checks.RequireFile(initrdPath, "initrd image", `pass --initrd `) } if modulesDir != "" { - checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules or set "default_modules_dir"`) - } - if packagesPath != "" { - checks.RequireFile(packagesPath, "packages manifest", `pass --packages `) + checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules `) } return checks.Err("image register failed") } -func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir, packagesPath string) error { +func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir string) error { checks := system.NewPreflight() checks.RequireFile(rootfsPath, "rootfs image", `re-register the image with a valid rootfs`) checks.RequireFile(kernelPath, "kernel image", `re-register the image with a valid kernel`) @@ -363,22 +363,15 @@ func validateImagePromotePaths(rootfsPath, kernelPath, initrdPath, modulesDir, p if modulesDir != "" { checks.RequireDir(modulesDir, "kernel modules dir", `re-register the image with a valid modules dir`) } - if packagesPath != "" { - checks.RequireFile(packagesPath, "packages manifest", `re-register the image with a valid packages manifest`) - } return checks.Err("image promote failed") } -func writePackagesMetadata(rootfsPath, packagesPath string) error { - if rootfsPath == "" || packagesPath == "" { +func writePackagesMetadata(rootfsPath string, packages []string) error { + if rootfsPath == "" || len(packages) == 0 { return nil } - lines, err := system.ReadNormalizedLines(packagesPath) - if err != nil { - return err - } metadataPath := rootfsPath + ".packages.sha256" - return os.WriteFile(metadataPath, []byte(packagesHash(lines)+"\n"), 0o644) + return os.WriteFile(metadataPath, []byte(packagesHash(packages)+"\n"), 0o644) } func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image, error) { @@ -406,3 +399,52 @@ func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image, } return image, nil } + +func stageManagedBootArtifacts(ctx context.Context, runner system.CommandRunner, artifactDir, kernelSource, initrdSource, modulesSource string) (string, string, string, error) { + kernelPath := filepath.Join(artifactDir, "kernel") + if err := system.CopyFilePreferClone(kernelSource, kernelPath); err != nil { + return "", "", "", err + } + initrdPath := "" + if strings.TrimSpace(initrdSource) != "" { + initrdPath = filepath.Join(artifactDir, "initrd.img") + if err := system.CopyFilePreferClone(initrdSource, initrdPath); err != nil { + return "", "", "", err + } + } + modulesDir := "" + if strings.TrimSpace(modulesSource) != "" { + modulesDir = filepath.Join(artifactDir, "modules") + if err := os.MkdirAll(modulesDir, 0o755); err != nil { + return "", "", "", err + } + if err := system.CopyDirContents(ctx, runner, modulesSource, modulesDir, false); err != nil { + return "", "", "", err + } + } + return kernelPath, initrdPath, modulesDir, nil +} + +func imageBuildMetadataPackages(docker bool) []string { + packages := imagepreset.DebianBasePackages() + if docker { + packages = append(packages, "#feature:docker") + } + return packages +} + +func stageOptionalArtifactPath(artifactDir, stagedPath, name string) string { + if strings.TrimSpace(stagedPath) == "" { + return "" + } + return filepath.Join(artifactDir, name) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/daemon/logger_test.go b/internal/daemon/logger_test.go index d848064..4ad9e29 100644 --- a/internal/daemon/logger_test.go +++ b/internal/daemon/logger_test.go @@ -69,6 +69,7 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) { if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("write vsock helper: %v", err) } + t.Setenv("BANGER_VSOCK_AGENT_BIN", vsockHelper) rootfsPath := filepath.Join(t.TempDir(), "rootfs.ext4") kernelPath := filepath.Join(t.TempDir(), "vmlinux") for _, path := range []string{rootfsPath, kernelPath} { @@ -109,7 +110,6 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) { BridgeIP: model.DefaultBridgeIP, DefaultDNS: model.DefaultDNS, FirecrackerBin: firecrackerBin, - VSockAgentPath: vsockHelper, StatsPollInterval: model.DefaultStatsPollInterval, }, runner: runner, @@ -148,11 +148,10 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { baseRootfs := filepath.Join(t.TempDir(), "base.ext4") kernelPath := filepath.Join(t.TempDir(), "vmlinux") - packagesPath := filepath.Join(t.TempDir(), "packages.apt") sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") firecrackerBin := filepath.Join(t.TempDir(), "firecracker") vsockHelper := filepath.Join(t.TempDir(), "banger-vsock-agent") - for _, path := range []string{baseRootfs, kernelPath, packagesPath, sshKeyPath} { + for _, path := range []string{baseRootfs, kernelPath, sshKeyPath} { if err := os.WriteFile(path, []byte("artifact"), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } @@ -160,6 +159,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { if err := os.WriteFile(vsockHelper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("write %s: %v", vsockHelper, err) } + t.Setenv("BANGER_VSOCK_AGENT_BIN", vsockHelper) if err := os.WriteFile(firecrackerBin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("write %s: %v", firecrackerBin, err) } @@ -175,18 +175,26 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { if err != nil { t.Fatalf("newDaemonLogger: %v", err) } + baseImage := model.Image{ + ID: "base-image", + Name: "base-image", + RootfsPath: baseRootfs, + KernelPath: kernelPath, + CreatedAt: model.Now(), + UpdatedAt: model.Now(), + } + if err := store.UpsertImage(ctx, baseImage); err != nil { + t.Fatalf("UpsertImage(base): %v", err) + } d := &Daemon{ layout: paths.Layout{ StateDir: stateDir, ImagesDir: imagesDir, }, config: model.DaemonConfig{ - RuntimeDir: t.TempDir(), - DefaultImageName: "default", - DefaultPackagesFile: packagesPath, - SSHKeyPath: sshKeyPath, - FirecrackerBin: firecrackerBin, - VSockAgentPath: vsockHelper, + DefaultImageName: "base-image", + SSHKeyPath: sshKeyPath, + FirecrackerBin: firecrackerBin, }, store: store, runner: runner, @@ -195,7 +203,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { if _, err := fmt.Fprintln(spec.BuildLog, "builder-stdout"); err != nil { return err } - if spec.BaseRootfs != baseRootfs || spec.KernelPath != kernelPath || spec.PackagesPath != packagesPath { + if spec.SourceRootfs != baseRootfs || spec.KernelPath == kernelPath || len(spec.Packages) == 0 { t.Fatalf("unexpected image build spec: %+v", spec) } return errors.New("builder failed") @@ -204,7 +212,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { _, err = d.BuildImage(ctx, api.ImageBuildParams{ Name: "broken-image", - BaseRootfs: baseRootfs, + FromImage: baseImage.Name, KernelPath: kernelPath, }) if err == nil || !strings.Contains(err.Error(), "inspect ") { diff --git a/internal/daemon/preflight.go b/internal/daemon/preflight.go index c4dd41c..0d3c251 100644 --- a/internal/daemon/preflight.go +++ b/internal/daemon/preflight.go @@ -5,7 +5,6 @@ import ( "strings" "banger/internal/model" - "banger/internal/paths" "banger/internal/system" ) @@ -50,16 +49,18 @@ func (d *Daemon) addNATPrereqs(ctx context.Context, checks *system.Preflight) { } func (d *Daemon) addBaseStartPrereqs(checks *system.Preflight, image model.Image) { - hint := paths.RuntimeBundleHint() - d.addBaseStartCommandPrereqs(checks) - checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) - checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`) + checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`) + if helper, err := d.vsockAgentBinary(); err == nil { + checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`) + } else { + checks.Addf("%v", err) + } checks.RequireFile(vsockHostDevicePath, "vsock host device", "load the vhost_vsock kernel module on the host") - checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle") - checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`) + checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid registered image") + checks.RequireFile(image.KernelPath, "kernel image", `re-register or rebuild the image with a valid kernel`) if strings.TrimSpace(image.InitrdPath) != "" { - checks.RequireFile(image.InitrdPath, "initrd image", `set "default_initrd" or refresh the runtime bundle`) + checks.RequireFile(image.InitrdPath, "initrd image", `re-register or rebuild the image with a valid initrd`) } } @@ -70,30 +71,26 @@ func (d *Daemon) addBaseStartCommandPrereqs(checks *system.Preflight) { } func (d *Daemon) addImageBuildPrereqs(ctx context.Context, checks *system.Preflight, baseRootfs, kernelPath, initrdPath, modulesDir, sizeSpec string) { - hint := paths.RuntimeBundleHint() - for _, command := range []string{"sudo", "ip", "pgrep", "chown", "chmod", "kill"} { checks.RequireCommand(command, toolHint(command)) } for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} { checks.RequireCommand(command, toolHint(command)) } - checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) - checks.RequireFile(d.config.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`) - checks.RequireExecutable(d.config.VSockAgentPath, "vsock agent", `run 'make build' or refresh the runtime bundle`) - checks.RequireFile(baseRootfs, "base rootfs image", `pass --base-rootfs or set "default_base_rootfs"`) - checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`) - checks.RequireFile(d.config.DefaultPackagesFile, "package manifest", `set "default_packages_file" or refresh the runtime bundle`) + checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`) + checks.RequireFile(d.config.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) + if helper, err := d.vsockAgentBinary(); err == nil { + checks.RequireExecutable(helper, "vsock agent helper", `run 'make build' or reinstall banger`) + } else { + checks.Addf("%v", err) + } + checks.RequireFile(baseRootfs, "base image rootfs", `pass --from-image with a valid registered image`) + checks.RequireFile(kernelPath, "kernel image", `pass --kernel or build from an image with a valid kernel`) if strings.TrimSpace(initrdPath) != "" { - checks.RequireFile(initrdPath, "initrd image", `pass --initrd or set "default_initrd"`) + checks.RequireFile(initrdPath, "initrd image", `pass --initrd or build from an image with a valid initrd`) } if strings.TrimSpace(modulesDir) != "" { - checks.RequireDir(modulesDir, "modules directory", `pass --modules or set "default_modules_dir"`) - } - if strings.TrimSpace(d.config.DefaultPackagesFile) != "" { - if _, err := system.ReadNormalizedLines(d.config.DefaultPackagesFile); err != nil { - checks.Addf("package manifest at %s is invalid: %v", d.config.DefaultPackagesFile, err) - } + checks.RequireDir(modulesDir, "modules directory", `pass --modules or build from an image with a valid modules dir`) } if strings.TrimSpace(sizeSpec) != "" { checks.RequireCommand("e2fsck", toolHint("e2fsck")) diff --git a/internal/daemon/runtime_assets.go b/internal/daemon/runtime_assets.go new file mode 100644 index 0000000..16c4cf6 --- /dev/null +++ b/internal/daemon/runtime_assets.go @@ -0,0 +1,15 @@ +package daemon + +import ( + "fmt" + + "banger/internal/paths" +) + +func (d *Daemon) vsockAgentBinary() (string, error) { + path, err := paths.CompanionBinaryPath("banger-vsock-agent") + if err != nil { + return "", fmt.Errorf("vsock agent helper not available: %w", err) + } + return path, nil +} diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index a602f1a..251f039 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -16,7 +16,7 @@ import ( "banger/internal/guest" "banger/internal/guestconfig" "banger/internal/model" - "banger/internal/paths" + "banger/internal/namegen" "banger/internal/system" "banger/internal/vmdns" "banger/internal/vsockagent" @@ -998,13 +998,20 @@ func (d *Daemon) createTap(ctx context.Context, tap string) error { func (d *Daemon) firecrackerBinary() (string, error) { if d.config.FirecrackerBin == "" { - return "", fmt.Errorf("firecracker binary not configured; %s", paths.RuntimeBundleHint()) + return "", fmt.Errorf("firecracker binary not configured; install firecracker or set firecracker_bin") } path := d.config.FirecrackerBin - if !exists(path) { - return "", fmt.Errorf("firecracker binary not found at %s; %s", path, paths.RuntimeBundleHint()) + if strings.ContainsRune(path, os.PathSeparator) { + if !exists(path) { + return "", fmt.Errorf("firecracker binary not found at %s; install firecracker or set firecracker_bin", path) + } + return path, nil } - return path, nil + resolved, err := system.LookupExecutable(path) + if err != nil { + return "", fmt.Errorf("firecracker binary %q not found in PATH; install firecracker or set firecracker_bin", path) + } + return resolved, nil } func (d *Daemon) ensureSocketAccess(ctx context.Context, socketPath, label string) error { @@ -1190,14 +1197,9 @@ func (d *Daemon) killVMProcess(ctx context.Context, pid int) error { } func (d *Daemon) generateName(ctx context.Context) (string, error) { - if exists(d.config.NamegenPath) { - out, err := d.runner.Run(ctx, d.config.NamegenPath) - if err == nil { - name := strings.TrimSpace(string(out)) - if name != "" { - return name, nil - } - } + _ = ctx + if name := strings.TrimSpace(namegen.Generate()); name != "" { + return name, nil } return "vm-" + strconv.FormatInt(time.Now().Unix(), 10), nil } diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 3298a69..74c1881 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -4,13 +4,14 @@ import ( "context" "crypto/rand" "crypto/rsa" + "crypto/tls" "crypto/x509" "encoding/pem" "errors" "fmt" + "math/big" "net" "net/http" - "net/http/httptest" "os" "os/exec" "path/filepath" @@ -183,6 +184,7 @@ func TestRebuildDNSIncludesOnlyLiveRunningVMs(t *testing.T) { server, err := vmdns.New("127.0.0.1:0", nil) if err != nil { + skipIfSocketRestricted(t, err) t.Fatalf("vmdns.New: %v", err) } t.Cleanup(func() { @@ -274,6 +276,7 @@ func TestHealthVMReturnsHealthyForRunningGuest(t *testing.T) { vsockSock := filepath.Join(t.TempDir(), "fc.vsock") listener, err := net.Listen("unix", vsockSock) if err != nil { + skipIfSocketRestricted(t, err) t.Fatalf("listen vsock: %v", err) } t.Cleanup(func() { @@ -367,6 +370,7 @@ func TestPingVMAliasReturnsAliveForHealthyVM(t *testing.T) { vsockSock := filepath.Join(t.TempDir(), "fc.vsock") listener, err := net.Listen("unix", vsockSock) if err != nil { + skipIfSocketRestricted(t, err) t.Fatalf("listen vsock: %v", err) } t.Cleanup(func() { @@ -441,32 +445,17 @@ func TestPortsVMReturnsEnrichedPortsAndWebSchemes(t *testing.T) { _ = fake.Wait() }) - webServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webAddr := startHTTPServerOnTCP4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) - t.Cleanup(webServer.Close) - webAddr, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(webServer.URL, "http://")) - if err != nil { - t.Fatalf("ResolveTCPAddr: %v", err) - } - tlsServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tlsAddr := startHTTPSServerOnTCP4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) })) - tlsListener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen tls: %v", err) - } - tlsServer.Listener = tlsListener - tlsServer.StartTLS() - t.Cleanup(tlsServer.Close) - tlsAddr, err := net.ResolveTCPAddr("tcp", strings.TrimPrefix(tlsServer.URL, "https://")) - if err != nil { - t.Fatalf("ResolveTCPAddr(tls): %v", err) - } vsockSock := filepath.Join(t.TempDir(), "fc.vsock") listener, err := net.Listen("unix", vsockSock) if err != nil { + skipIfSocketRestricted(t, err) t.Fatalf("listen vsock: %v", err) } t.Cleanup(func() { @@ -1263,6 +1252,7 @@ func startFakeFirecrackerAPI(t *testing.T, apiSock string) { } listener, err := net.Listen("unix", apiSock) if err != nil { + skipIfSocketRestricted(t, err) t.Fatalf("listen unix %s: %v", apiSock, err) } mux := http.NewServeMux() @@ -1283,6 +1273,72 @@ func startFakeFirecrackerAPI(t *testing.T, apiSock string) { }) } +func skipIfSocketRestricted(t *testing.T, err error) { + t.Helper() + if err == nil { + return + } + if strings.Contains(strings.ToLower(err.Error()), "operation not permitted") { + t.Skipf("socket creation is restricted in this environment: %v", err) + } +} + +func startHTTPServerOnTCP4(t *testing.T, handler http.Handler) *net.TCPAddr { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + skipIfSocketRestricted(t, err) + t.Fatalf("listen http: %v", err) + } + server := &http.Server{Handler: handler} + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(func() { + _ = server.Close() + }) + return listener.Addr().(*net.TCPAddr) +} + +func startHTTPSServerOnTCP4(t *testing.T, handler http.Handler) *net.TCPAddr { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + t.Fatalf("X509KeyPair: %v", err) + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + skipIfSocketRestricted(t, err) + t.Fatalf("listen https: %v", err) + } + server := &http.Server{Handler: handler} + go func() { + _ = server.Serve(tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{cert}})) + }() + t.Cleanup(func() { + _ = server.Close() + }) + return listener.Addr().(*net.TCPAddr) +} + type processKillingRunner struct { *scriptedRunner proc *exec.Cmd diff --git a/internal/imagepreset/preset.go b/internal/imagepreset/preset.go new file mode 100644 index 0000000..d1de16e --- /dev/null +++ b/internal/imagepreset/preset.go @@ -0,0 +1,57 @@ +package imagepreset + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +var debianBase = []string{ + "make", + "git", + "less", + "tree", + "ca-certificates", + "curl", + "wget", + "iproute2", + "vim", + "tmux", +} + +var voidBase = []string{ + "base-minimal", + "base-devel", + "bash", + "ca-certificates", + "curl", + "docker", + "docker-compose", + "e2fsprogs", + "git", + "iproute2", + "less", + "make", + "openssh", + "procps-ng", + "runit", + "shadow", + "sudo", + "tmux", + "tree", + "vim", + "wget", +} + +func DebianBasePackages() []string { + return append([]string(nil), debianBase...) +} + +func VoidBasePackages() []string { + return append([]string(nil), voidBase...) +} + +func Hash(lines []string) string { + sum := sha256.Sum256([]byte(strings.Join(lines, "\n") + "\n")) + return fmt.Sprintf("%x", sum) +} diff --git a/internal/model/types.go b/internal/model/types.go index 2955765..f986505 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -35,15 +35,10 @@ const ( ) type DaemonConfig struct { - RuntimeDir string LogLevel string WebListenAddr string FirecrackerBin string SSHKeyPath string - NamegenPath string - CustomizeScript string - VSockAgentPath string - DefaultWorkSeed string AutoStopStaleAfter time.Duration StatsPollInterval time.Duration MetricsPollInterval time.Duration @@ -53,12 +48,6 @@ type DaemonConfig struct { TapPoolSize int DefaultDNS string DefaultImageName string - DefaultRootfs string - DefaultBaseRootfs string - DefaultKernel string - DefaultInitrd string - DefaultModulesDir string - DefaultPackagesFile string } type Image struct { @@ -71,7 +60,6 @@ type Image struct { 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"` SeededSSHPublicKeyFingerprint string `json:"seeded_ssh_public_key_fingerprint,omitempty"` Docker bool `json:"docker"` @@ -152,7 +140,7 @@ type VMSetRequest struct { type ImageBuildRequest struct { Name string - BaseRootfs string + FromImage string Size string KernelPath string InitrdPath string diff --git a/internal/namegen/namegen.go b/internal/namegen/namegen.go new file mode 100644 index 0000000..b3edfad --- /dev/null +++ b/internal/namegen/namegen.go @@ -0,0 +1,71 @@ +package namegen + +import ( + "crypto/rand" + "encoding/binary" +) + +var adjectives = []string{ + "ace", "apt", "fit", "fun", "odd", "top", "able", "beau", "bold", "calm", + "chic", "cool", "deep", "deft", "easy", "epic", "fair", "fine", "free", "full", + "game", "glad", "glow", "good", "holy", "keen", "kind", "lean", "mild", "neat", + "nice", "open", "pure", "real", "snug", "spry", "tidy", "true", "warm", "wavy", + "wise", "adept", "agile", "alert", "alive", "ample", "angel", "awake", "aware", "brave", + "brisk", "chill", "clean", "clear", "close", "comic", "eager", "elite", "first", "fleet", + "fresh", "grace", "grand", "great", "happy", "hardy", "ideal", "jolly", "light", "lithe", + "loyal", "lucid", "lucky", "lunar", "magic", "merry", "nifty", "noble", "peppy", "perky", + "proud", "quick", "quiet", "ready", "regal", "savvy", "sharp", "smart", "solid", "sound", + "sunny", "super", "sweet", "swift", "vivid", "witty", "zesty", +} + +var substantives = []string{ + "ox", "aim", "air", "arm", "bud", "day", "hay", "jam", "jay", "joy", + "key", "map", "may", "nod", "ore", "pen", "sky", "sun", "way", "zen", + "ant", "ape", "auk", "bat", "bee", "cat", "cod", "cow", "dog", "elk", + "fox", "hen", "owl", "pig", "ram", "rat", "yak", "boar", "buck", "bull", + "calf", "carp", "crab", "crow", "deer", "dove", "fish", "foal", "frog", "goat", + "gull", "hare", "hawk", "ibex", "kiwi", "kudu", "lamb", "lion", "lynx", "mink", + "mole", "mule", "newt", "orca", "oryx", "puma", "seal", "slug", "stag", "swan", + "tern", "toad", "tuna", "wasp", "wolf", "zebu", "bison", "camel", "crane", "eagle", + "finch", "goose", "heron", "hippo", "horse", "hyena", "koala", "llama", "macaw", "moose", + "otter", "quail", "raven", "robin", "shark", "sheep", "shrew", "skunk", "sloth", "snail", + "squid", "tapir", "tiger", "trout", "whale", "zebra", "ally", "arch", "area", "aura", + "axis", "bank", "barn", "beam", "bell", "belt", "bend", "bird", "boat", "bond", + "book", "boot", "bowl", "brim", "calm", "camp", "card", "care", "cell", "city", + "clan", "club", "code", "core", "crux", "dawn", "deal", "film", "firm", "flag", + "flow", "foam", "gate", "gift", "glow", "hall", "hand", "harp", "hill", "home", + "hope", "host", "idea", "isle", "item", "keel", "knot", "land", "leaf", "link", + "lion", "loom", "love", "luck", "mark", "moon", "moss", "nook", "note", "pact", + "page", "path", "peak", "poem", "port", "ring", "road", "rock", "roof", "rule", + "sail", "seal", "seed", "song", "star", "tide", "tree", "tune", "walk", "ward", + "wave", "well", "wind", "wing", "wish", "wood", "work", "zone", "amity", "asset", + "bloom", "brook", "bunch", "charm", "chart", "cheer", "chord", "cliff", "cloud", "coast", + "comet", "craft", "crane", "crest", "crowd", "crown", "cycle", "faith", "field", "flame", + "fleet", "focus", "forge", "frame", "fruit", "glade", "grace", "grain", "grove", "guide", + "guild", "haven", "heart", "honey", "honor", "humor", "image", "index", "jewel", "judge", + "kudos", "lumen", "lunar", "magic", "march", "marsh", "mercy", "model", "moral", "music", + "niche", "oasis", "ocean", "opera", "orbit", "order", "peace", "pearl", "petal", "phase", + "piano", "pilot", "place", "plaza", "prism", "proof", "pulse", "quest", "quiet", "quill", + "radar", "rally", "range", "realm", "reign", "river", "route", "scene", "scope", "score", + "shade", "shape", "shore", "skill", "spark", "spice", "spire", "spoke", "stone", "story", + "table", "token", "trend", "tribe", "trust", "unity", "valor", "value", "verse", "vista", + "voice", "world", +} + +func Generate() string { + if len(adjectives) == 0 || len(substantives) == 0 { + return "" + } + return adjectives[randomIndex(len(adjectives))] + "-" + substantives[randomIndex(len(substantives))] +} + +func randomIndex(length int) int { + if length <= 1 { + return 0 + } + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return 0 + } + return int(binary.BigEndian.Uint64(buf[:]) % uint64(length)) +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 8608a19..0eeacba 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -5,10 +5,7 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" - - "banger/internal/runtimebundle" ) type Layout struct { @@ -69,71 +66,6 @@ func Ensure(layout Layout) error { var executablePath = os.Executable -func ResolveRuntimeDir(configuredRuntimeDir, deprecatedRepoRoot string) string { - for _, candidate := range []string{ - os.Getenv("BANGER_RUNTIME_DIR"), - os.Getenv("BANGER_REPO_ROOT"), - configuredRuntimeDir, - deprecatedRepoRoot, - } { - if candidate = strings.TrimSpace(candidate); candidate != "" { - return filepath.Clean(candidate) - } - } - exe, err := executablePath() - if err != nil { - return "" - } - exeDir := filepath.Dir(exe) - if filepath.Base(exeDir) == "bin" { - if filepath.Base(filepath.Dir(exeDir)) == "build" { - buildRuntimeDir := filepath.Clean(filepath.Join(exeDir, "..", "runtime")) - if HasRuntimeBundle(buildRuntimeDir) { - return buildRuntimeDir - } - } - installRuntimeDir := filepath.Clean(filepath.Join(exeDir, "..", "lib", "banger")) - if HasRuntimeBundle(installRuntimeDir) { - return installRuntimeDir - } - } - for _, sourceRuntimeDir := range []string{ - filepath.Join(exeDir, "build", "runtime"), - filepath.Join(exeDir, "runtime"), - } { - if HasRuntimeBundle(sourceRuntimeDir) { - return sourceRuntimeDir - } - } - return "" -} - -func HasRuntimeBundle(dir string) bool { - if strings.TrimSpace(dir) == "" { - return false - } - if _, err := runtimebundle.LoadBundleMetadata(dir); err == nil { - return true - } - required := []string{ - "firecracker", - "customize.sh", - "packages.apt", - "wtf/root/boot/vmlinux-6.8.0-94-generic", - } - for _, name := range required { - if _, err := os.Stat(filepath.Join(dir, name)); err != nil { - return false - } - } - for _, name := range []string{"rootfs-docker.ext4", "rootfs.ext4"} { - if _, err := os.Stat(filepath.Join(dir, name)); err == nil { - return true - } - } - return false -} - func BangerdPath() (string, error) { if env := os.Getenv("BANGER_DAEMON_BIN"); env != "" { return env, nil @@ -154,8 +86,33 @@ func BangerdPath() (string, error) { return "", errors.New("bangerd binary not found next to banger; run `make build`") } -func RuntimeBundleHint() string { - return "run `make runtime-bundle` or set runtime_dir in ~/.config/banger/config.toml" +func CompanionBinaryPath(name string) (string, error) { + envNames := []string{ + "BANGER_" + strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(name)) + "_BIN", + } + if trimmed, ok := strings.CutPrefix(name, "banger-"); ok { + envNames = append(envNames, "BANGER_"+strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(trimmed))+"_BIN") + } + for _, envName := range envNames { + if env := strings.TrimSpace(os.Getenv(envName)); env != "" { + return env, nil + } + } + exe, err := executablePath() + if err != nil { + return "", err + } + exeDir := filepath.Dir(exe) + for _, candidate := range []string{ + filepath.Join(exeDir, name), + filepath.Join(exeDir, "..", "lib", "banger", name), + filepath.Join(exeDir, "..", "libexec", "banger", name), + } { + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + return "", fmt.Errorf("%s companion binary not found; run `make build` or reinstall banger", name) } func getenvDefault(key, fallback string) string { @@ -164,7 +121,3 @@ func getenvDefault(key, fallback string) string { } return fallback } - -func RuntimeFallbackLabel() string { - return strconv.Itoa(os.Getuid()) -} diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go index 68771a9..5ec7a53 100644 --- a/internal/paths/paths_test.go +++ b/internal/paths/paths_test.go @@ -1,44 +1,29 @@ package paths import ( - "encoding/json" "os" "path/filepath" "testing" - - "banger/internal/runtimebundle" ) -func TestResolveRuntimeDirPrefersEnv(t *testing.T) { - t.Setenv("BANGER_RUNTIME_DIR", "/env/runtime") +func TestCompanionBinaryPathPrefersEnv(t *testing.T) { + t.Setenv("BANGER_VSOCK_AGENT_BIN", "/tmp/custom-vsock-agent") - if got := ResolveRuntimeDir("/config/runtime", "/deprecated/repo"); got != "/env/runtime" { - t.Fatalf("ResolveRuntimeDir() = %q, want /env/runtime", got) + got, err := CompanionBinaryPath("banger-vsock-agent") + if err != nil { + t.Fatalf("CompanionBinaryPath: %v", err) + } + if got != "/tmp/custom-vsock-agent" { + t.Fatalf("CompanionBinaryPath() = %q", got) } } -func TestResolveRuntimeDirUsesInstalledLayout(t *testing.T) { +func TestCompanionBinaryPathUsesSiblingBinary(t *testing.T) { root := t.TempDir() - runtimeDir := filepath.Join(root, "lib", "banger") - createRuntimeBundle(t, runtimeDir) - - origExecutablePath := executablePath - executablePath = func() (string, error) { - return filepath.Join(root, "bin", "banger"), nil + companion := filepath.Join(root, "banger-vsock-agent") + if err := os.WriteFile(companion, []byte("test"), 0o755); err != nil { + t.Fatalf("write companion: %v", err) } - t.Cleanup(func() { - executablePath = origExecutablePath - }) - - if got := ResolveRuntimeDir("", ""); got != runtimeDir { - t.Fatalf("ResolveRuntimeDir() = %q, want %q", got, runtimeDir) - } -} - -func TestResolveRuntimeDirUsesBuildRuntimeForSourceCheckoutBinary(t *testing.T) { - root := t.TempDir() - runtimeDir := filepath.Join(root, "build", "runtime") - createRuntimeBundle(t, runtimeDir) origExecutablePath := executablePath executablePath = func() (string, error) { @@ -48,64 +33,38 @@ func TestResolveRuntimeDirUsesBuildRuntimeForSourceCheckoutBinary(t *testing.T) executablePath = origExecutablePath }) - if got := ResolveRuntimeDir("", ""); got != runtimeDir { - t.Fatalf("ResolveRuntimeDir() = %q, want %q", got, runtimeDir) + got, err := CompanionBinaryPath("banger-vsock-agent") + if err != nil { + t.Fatalf("CompanionBinaryPath: %v", err) + } + if got != companion { + t.Fatalf("CompanionBinaryPath() = %q, want %q", got, companion) } } -func TestResolveRuntimeDirUsesBuildRuntimeForBuildBinExecutable(t *testing.T) { +func TestCompanionBinaryPathUsesInstalledLibDir(t *testing.T) { root := t.TempDir() - runtimeDir := filepath.Join(root, "build", "runtime") - createRuntimeBundle(t, runtimeDir) + companion := filepath.Join(root, "lib", "banger", "banger-vsock-agent") + if err := os.MkdirAll(filepath.Dir(companion), 0o755); err != nil { + t.Fatalf("mkdir companion dir: %v", err) + } + if err := os.WriteFile(companion, []byte("test"), 0o755); err != nil { + t.Fatalf("write companion: %v", err) + } origExecutablePath := executablePath executablePath = func() (string, error) { - return filepath.Join(root, "build", "bin", "banger"), nil + return filepath.Join(root, "bin", "banger"), nil } t.Cleanup(func() { executablePath = origExecutablePath }) - if got := ResolveRuntimeDir("", ""); got != runtimeDir { - t.Fatalf("ResolveRuntimeDir() = %q, want %q", got, runtimeDir) - } -} - -func createRuntimeBundle(t *testing.T, runtimeDir string) { - t.Helper() - metadata := runtimebundle.BundleMetadata{ - FirecrackerBin: "bin/firecracker", - SSHKeyPath: "keys/id_ed25519", - NamegenPath: "bin/namegen", - CustomizeScript: "scripts/customize.sh", - VSockAgentPath: "bin/banger-vsock-agent", - DefaultPackages: "config/packages.apt", - DefaultRootfs: "images/rootfs-docker.ext4", - DefaultKernel: "kernels/vmlinux", - } - for _, rel := range []string{ - metadata.FirecrackerBin, - metadata.SSHKeyPath, - metadata.NamegenPath, - metadata.CustomizeScript, - metadata.VSockAgentPath, - metadata.DefaultPackages, - metadata.DefaultRootfs, - metadata.DefaultKernel, - } { - path := filepath.Join(runtimeDir, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) - } - if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } - } - data, err := json.Marshal(metadata) + got, err := CompanionBinaryPath("banger-vsock-agent") if err != nil { - t.Fatalf("Marshal: %v", err) + t.Fatalf("CompanionBinaryPath: %v", err) } - if err := os.WriteFile(filepath.Join(runtimeDir, runtimebundle.BundleMetadataFile), data, 0o644); err != nil { - t.Fatalf("write bundle metadata: %v", err) + if got != companion { + t.Fatalf("CompanionBinaryPath() = %q, want %q", got, companion) } } diff --git a/internal/runtimebundle/bundle.go b/internal/runtimebundle/bundle.go deleted file mode 100644 index 111cb60..0000000 --- a/internal/runtimebundle/bundle.go +++ /dev/null @@ -1,497 +0,0 @@ -package runtimebundle - -import ( - "archive/tar" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "sort" - "strings" - - toml "github.com/pelletier/go-toml" -) - -type Manifest struct { - Version string `toml:"version"` - URL string `toml:"url"` - SHA256 string `toml:"sha256"` - BundleRoot string `toml:"bundle_root"` - RequiredPaths []string `toml:"required_paths"` - BundleMeta BundleMetadata `toml:"bundle_metadata"` -} - -type BundleMetadata struct { - FirecrackerBin string `json:"firecracker_bin" toml:"firecracker_bin"` - SSHKeyPath string `json:"ssh_key_path" toml:"ssh_key_path"` - NamegenPath string `json:"namegen_path" toml:"namegen_path"` - CustomizeScript string `json:"customize_script" toml:"customize_script"` - VSockAgentPath string `json:"vsock_agent_path,omitempty" toml:"vsock_agent_path"` - VSockPingHelperPath string `json:"vsock_ping_helper_path,omitempty" toml:"vsock_ping_helper_path"` - DefaultPackages string `json:"default_packages_file" toml:"default_packages_file"` - DefaultRootfs string `json:"default_rootfs" toml:"default_rootfs"` - DefaultBaseRootfs string `json:"default_base_rootfs,omitempty" toml:"default_base_rootfs"` - DefaultWorkSeed string `json:"default_work_seed,omitempty" toml:"default_work_seed"` - DefaultKernel string `json:"default_kernel" toml:"default_kernel"` - DefaultInitrd string `json:"default_initrd,omitempty" toml:"default_initrd"` - DefaultModulesDir string `json:"default_modules_dir,omitempty" toml:"default_modules_dir"` -} - -const BundleMetadataFile = "bundle.json" - -func LoadManifest(path string) (Manifest, error) { - data, err := os.ReadFile(path) - if err != nil { - return Manifest{}, err - } - var manifest Manifest - if err := toml.Unmarshal(data, &manifest); err != nil { - return Manifest{}, err - } - manifest.BundleRoot = strings.TrimSpace(manifest.BundleRoot) - manifest.URL = strings.TrimSpace(manifest.URL) - manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256)) - manifest.BundleMeta = normalizeBundleMetadata(manifest.BundleMeta) - for i, required := range manifest.RequiredPaths { - manifest.RequiredPaths[i] = filepath.Clean(strings.TrimSpace(required)) - } - sort.Strings(manifest.RequiredPaths) - if len(manifest.RequiredPaths) == 0 { - return Manifest{}, fmt.Errorf("runtime bundle manifest %s has no required_paths", path) - } - return manifest, nil -} - -func Bootstrap(ctx context.Context, manifest Manifest, manifestPath, outDir string) error { - if manifest.URL == "" { - return fmt.Errorf("runtime bundle manifest %s has no url; point a local manifest copy at a staged or published runtime bundle archive", manifestPath) - } - if manifest.SHA256 == "" { - return fmt.Errorf("runtime bundle manifest %s has no sha256; add the checksum for the staged or published runtime bundle archive", manifestPath) - } - manifestDir := filepath.Dir(manifestPath) - parentDir := filepath.Dir(outDir) - if err := os.MkdirAll(parentDir, 0o755); err != nil { - return err - } - - workDir, err := os.MkdirTemp(parentDir, ".runtime-bundle-*") - if err != nil { - return err - } - defer os.RemoveAll(workDir) - - archivePath := filepath.Join(workDir, "bundle.tar.gz") - if err := downloadArchive(ctx, resolveSource(manifestDir, manifest.URL), archivePath); err != nil { - return err - } - sum, err := fileSHA256(archivePath) - if err != nil { - return err - } - if sum != manifest.SHA256 { - return fmt.Errorf("runtime bundle checksum mismatch: got %s want %s", sum, manifest.SHA256) - } - - extractDir := filepath.Join(workDir, "extract") - if err := extractTarGz(archivePath, extractDir); err != nil { - return err - } - - bundleDir := extractDir - if manifest.BundleRoot != "" { - bundleDir = filepath.Join(extractDir, manifest.BundleRoot) - } - if err := ValidateBundle(bundleDir, manifest.RequiredPaths); err != nil { - return err - } - if _, err := LoadBundleMetadata(bundleDir); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - - stageDir := filepath.Join(workDir, "stage") - if err := os.Rename(bundleDir, stageDir); err != nil { - return err - } - - if err := os.RemoveAll(outDir); err != nil { - return err - } - if err := os.Rename(stageDir, outDir); err != nil { - return err - } - return nil -} - -func ValidateBundle(bundleDir string, requiredPaths []string) error { - for _, rel := range requiredPaths { - if rel == "." || strings.HasPrefix(rel, "..") { - return fmt.Errorf("invalid required bundle path: %s", rel) - } - if _, err := os.Stat(filepath.Join(bundleDir, rel)); err != nil { - return fmt.Errorf("runtime bundle missing %s", rel) - } - } - return nil -} - -func Package(runtimeDir, outArchive string, manifest Manifest) (string, error) { - if err := ValidateBundle(runtimeDir, manifest.RequiredPaths); err != nil { - return "", err - } - metadata, err := metadataArchiveBytes(runtimeDir, manifest.BundleMeta) - if err != nil { - return "", err - } - if err := os.MkdirAll(filepath.Dir(outArchive), 0o755); err != nil { - return "", err - } - file, err := os.Create(outArchive) - if err != nil { - return "", err - } - defer file.Close() - - hash := sha256.New() - multi := io.MultiWriter(file, hash) - gz := gzip.NewWriter(multi) - defer gz.Close() - tw := tar.NewWriter(gz) - defer tw.Close() - - for _, rel := range manifest.RequiredPaths { - if err := addPathToArchive(tw, runtimeDir, manifest.BundleRoot, rel); err != nil { - return "", err - } - } - if len(metadata) != 0 { - if err := addBytesToArchive(tw, manifest.BundleRoot, BundleMetadataFile, metadata, 0o644); err != nil { - return "", err - } - } - if err := tw.Close(); err != nil { - return "", err - } - if err := gz.Close(); err != nil { - return "", err - } - return hex.EncodeToString(hash.Sum(nil)), nil -} - -func LoadBundleMetadata(runtimeDir string) (BundleMetadata, error) { - path := filepath.Join(runtimeDir, BundleMetadataFile) - data, err := os.ReadFile(path) - if err != nil { - return BundleMetadata{}, err - } - var meta BundleMetadata - if err := json.Unmarshal(data, &meta); err != nil { - return BundleMetadata{}, fmt.Errorf("parse %s: %w", path, err) - } - meta = normalizeBundleMetadata(meta) - if err := validateBundleMetadata(runtimeDir, meta); err != nil { - return BundleMetadata{}, err - } - return meta, nil -} - -func validateBundleMetadata(runtimeDir string, meta BundleMetadata) error { - required := []struct { - value string - label string - }{ - {meta.FirecrackerBin, "firecracker_bin"}, - {meta.SSHKeyPath, "ssh_key_path"}, - {meta.NamegenPath, "namegen_path"}, - {meta.CustomizeScript, "customize_script"}, - {meta.VSockAgentPath, "vsock_agent_path"}, - {meta.DefaultPackages, "default_packages_file"}, - {meta.DefaultRootfs, "default_rootfs"}, - {meta.DefaultKernel, "default_kernel"}, - } - for _, field := range required { - if strings.TrimSpace(field.value) == "" { - return fmt.Errorf("runtime bundle metadata missing %s", field.label) - } - } - for _, field := range []struct { - value string - label string - required bool - }{ - {meta.FirecrackerBin, "firecracker_bin", true}, - {meta.SSHKeyPath, "ssh_key_path", true}, - {meta.NamegenPath, "namegen_path", true}, - {meta.CustomizeScript, "customize_script", true}, - {meta.VSockAgentPath, "vsock_agent_path", true}, - {meta.DefaultPackages, "default_packages_file", true}, - {meta.DefaultRootfs, "default_rootfs", true}, - {meta.DefaultBaseRootfs, "default_base_rootfs", false}, - {meta.DefaultWorkSeed, "default_work_seed", false}, - {meta.DefaultKernel, "default_kernel", true}, - {meta.DefaultInitrd, "default_initrd", false}, - {meta.DefaultModulesDir, "default_modules_dir", false}, - } { - if strings.TrimSpace(field.value) == "" { - continue - } - resolved, err := resolveMetadataPath(runtimeDir, field.value) - if err != nil { - return fmt.Errorf("runtime bundle metadata %s: %w", field.label, err) - } - if _, err := os.Stat(resolved); err != nil { - if field.required || !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("runtime bundle metadata %s points to missing path %s", field.label, resolved) - } - } - } - return nil -} - -func resolveMetadataPath(runtimeDir, rel string) (string, error) { - rel = filepath.Clean(strings.TrimSpace(rel)) - if rel == "." || rel == "" || filepath.IsAbs(rel) || strings.HasPrefix(rel, "..") { - return "", fmt.Errorf("invalid relative path %q", rel) - } - return filepath.Join(runtimeDir, rel), nil -} - -func metadataArchiveBytes(runtimeDir string, meta BundleMetadata) ([]byte, error) { - meta = normalizeBundleMetadata(meta) - if strings.TrimSpace(meta.FirecrackerBin) == "" && - strings.TrimSpace(meta.SSHKeyPath) == "" && - strings.TrimSpace(meta.NamegenPath) == "" && - strings.TrimSpace(meta.CustomizeScript) == "" && - strings.TrimSpace(meta.VSockAgentPath) == "" && - strings.TrimSpace(meta.DefaultPackages) == "" && - strings.TrimSpace(meta.DefaultRootfs) == "" && - strings.TrimSpace(meta.DefaultBaseRootfs) == "" && - strings.TrimSpace(meta.DefaultWorkSeed) == "" && - strings.TrimSpace(meta.DefaultKernel) == "" && - strings.TrimSpace(meta.DefaultInitrd) == "" && - strings.TrimSpace(meta.DefaultModulesDir) == "" { - return nil, nil - } - if err := validateBundleMetadata(runtimeDir, meta); err != nil { - return nil, err - } - return json.MarshalIndent(meta, "", " ") -} - -func normalizeBundleMetadata(meta BundleMetadata) BundleMetadata { - meta.FirecrackerBin = strings.TrimSpace(meta.FirecrackerBin) - meta.SSHKeyPath = strings.TrimSpace(meta.SSHKeyPath) - meta.NamegenPath = strings.TrimSpace(meta.NamegenPath) - meta.CustomizeScript = strings.TrimSpace(meta.CustomizeScript) - meta.VSockAgentPath = strings.TrimSpace(meta.VSockAgentPath) - meta.VSockPingHelperPath = strings.TrimSpace(meta.VSockPingHelperPath) - if meta.VSockAgentPath == "" { - meta.VSockAgentPath = meta.VSockPingHelperPath - } - meta.DefaultPackages = strings.TrimSpace(meta.DefaultPackages) - meta.DefaultRootfs = strings.TrimSpace(meta.DefaultRootfs) - meta.DefaultBaseRootfs = strings.TrimSpace(meta.DefaultBaseRootfs) - meta.DefaultWorkSeed = strings.TrimSpace(meta.DefaultWorkSeed) - meta.DefaultKernel = strings.TrimSpace(meta.DefaultKernel) - meta.DefaultInitrd = strings.TrimSpace(meta.DefaultInitrd) - meta.DefaultModulesDir = strings.TrimSpace(meta.DefaultModulesDir) - return meta -} - -func addPathToArchive(tw *tar.Writer, runtimeDir, bundleRoot, rel string) error { - srcPath := filepath.Join(runtimeDir, rel) - info, err := os.Lstat(srcPath) - if err != nil { - return err - } - archiveName := rel - if bundleRoot != "" { - archiveName = filepath.Join(bundleRoot, rel) - } - if info.IsDir() { - header, err := tar.FileInfoHeader(info, "") - if err != nil { - return err - } - header.Name = filepath.ToSlash(archiveName) + "/" - if err := tw.WriteHeader(header); err != nil { - return err - } - entries, err := os.ReadDir(srcPath) - if err != nil { - return err - } - for _, entry := range entries { - childRel := filepath.Join(rel, entry.Name()) - if err := addPathToArchive(tw, runtimeDir, bundleRoot, childRel); err != nil { - return err - } - } - return nil - } - - header, err := tar.FileInfoHeader(info, "") - if err != nil { - return err - } - header.Name = filepath.ToSlash(archiveName) - if err := tw.WriteHeader(header); err != nil { - return err - } - file, err := os.Open(srcPath) - if err != nil { - return err - } - defer file.Close() - _, err = io.Copy(tw, file) - return err -} - -func addBytesToArchive(tw *tar.Writer, bundleRoot, rel string, data []byte, mode int64) error { - name := rel - if bundleRoot != "" { - name = filepath.Join(bundleRoot, rel) - } - header := &tar.Header{ - Name: filepath.ToSlash(name), - Mode: mode, - Size: int64(len(data)), - } - if err := tw.WriteHeader(header); err != nil { - return err - } - _, err := tw.Write(data) - return err -} - -func resolveSource(manifestDir, source string) string { - parsed, err := url.Parse(source) - if err == nil && parsed.Scheme != "" { - return source - } - if filepath.IsAbs(source) { - return source - } - return filepath.Join(manifestDir, source) -} - -func downloadArchive(ctx context.Context, source, dst string) error { - switch { - case strings.HasPrefix(source, "http://"), strings.HasPrefix(source, "https://"): - req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) - if err != nil { - return err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download runtime bundle: %s", resp.Status) - } - return writeFileFromReader(dst, resp.Body) - case strings.HasPrefix(source, "file://"): - parsed, err := url.Parse(source) - if err != nil { - return err - } - return copyFile(parsed.Path, dst) - default: - return copyFile(source, dst) - } -} - -func writeFileFromReader(dst string, reader io.Reader) error { - file, err := os.Create(dst) - if err != nil { - return err - } - defer file.Close() - _, err = io.Copy(file, reader) - return err -} - -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - return writeFileFromReader(dst, in) -} - -func extractTarGz(archivePath, outDir string) error { - if err := os.MkdirAll(outDir, 0o755); err != nil { - return err - } - file, err := os.Open(archivePath) - if err != nil { - return err - } - defer file.Close() - gz, err := gzip.NewReader(file) - if err != nil { - return err - } - defer gz.Close() - tr := tar.NewReader(gz) - for { - header, err := tr.Next() - if err == io.EOF { - return nil - } - if err != nil { - return err - } - name := filepath.Clean(header.Name) - if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) { - return fmt.Errorf("invalid archive entry: %s", header.Name) - } - target := filepath.Join(outDir, name) - switch header.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { - return err - } - case tar.TypeReg, tar.TypeRegA: - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - file, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) - if err != nil { - return err - } - if _, err := io.Copy(file, tr); err != nil { - file.Close() - return err - } - if err := file.Close(); err != nil { - return err - } - default: - return fmt.Errorf("unsupported archive entry type: %s", header.Name) - } - } -} - -func fileSHA256(path string) (string, error) { - file, err := os.Open(path) - if err != nil { - return "", err - } - defer file.Close() - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return "", err - } - return hex.EncodeToString(hash.Sum(nil)), nil -} diff --git a/internal/runtimebundle/bundle_test.go b/internal/runtimebundle/bundle_test.go deleted file mode 100644 index ea8c56c..0000000 --- a/internal/runtimebundle/bundle_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package runtimebundle - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) { - manifestDir := t.TempDir() - bundleData := buildArchive(t, map[string]string{ - "runtime/firecracker": "fc", - "runtime/id_ed25519": "key", - "runtime/namegen": "namegen", - "runtime/banger-vsock-agent": "agent", - "runtime/customize.sh": "#!/bin/bash\n", - "runtime/packages.sh": "#!/bin/bash\n", - "runtime/packages.apt": "vim\n", - "runtime/rootfs-docker.ext4": "rootfs", - "runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel", - "runtime/wtf/root/boot/initrd.img-6.8.0-94-generic": "initrd", - "runtime/wtf/root/lib/modules/6.8.0-94-generic/modules.dep": "dep", - "runtime/bundle.json": mustJSON(t, BundleMetadata{FirecrackerBin: "firecracker", SSHKeyPath: "id_ed25519", NamegenPath: "namegen", CustomizeScript: "customize.sh", VSockAgentPath: "banger-vsock-agent", DefaultPackages: "packages.apt", DefaultRootfs: "rootfs-docker.ext4", DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic"}), - }) - archivePath := filepath.Join(manifestDir, "bundle.tar.gz") - if err := os.WriteFile(archivePath, bundleData, 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - manifest := Manifest{ - URL: "./bundle.tar.gz", - SHA256: sha256Hex(bundleData), - BundleRoot: "runtime", - RequiredPaths: []string{"firecracker", "banger-vsock-agent", "customize.sh", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic", "wtf/root/lib/modules/6.8.0-94-generic"}, - } - outDir := filepath.Join(t.TempDir(), "runtime") - if err := Bootstrap(context.Background(), manifest, filepath.Join(manifestDir, "runtime-bundle.toml"), outDir); err != nil { - t.Fatalf("Bootstrap: %v", err) - } - for _, rel := range manifest.RequiredPaths { - if _, err := os.Stat(filepath.Join(outDir, rel)); err != nil { - t.Fatalf("runtime missing %s: %v", rel, err) - } - } -} - -func TestBootstrapRejectsChecksumMismatch(t *testing.T) { - manifestDir := t.TempDir() - archivePath := filepath.Join(manifestDir, "bundle.tar.gz") - if err := os.WriteFile(archivePath, []byte("not-a-tarball"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - manifest := Manifest{ - URL: "./bundle.tar.gz", - SHA256: strings.Repeat("0", 64), - BundleRoot: "runtime", - RequiredPaths: []string{"firecracker"}, - } - err := Bootstrap(context.Background(), manifest, filepath.Join(manifestDir, "runtime-bundle.toml"), filepath.Join(t.TempDir(), "runtime")) - if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { - t.Fatalf("Bootstrap() error = %v, want checksum mismatch", err) - } -} - -func TestBootstrapRejectsMissingURLWithLocalManifestGuidance(t *testing.T) { - manifest := Manifest{ - SHA256: strings.Repeat("0", 64), - BundleRoot: "runtime", - RequiredPaths: []string{"firecracker"}, - } - err := Bootstrap(context.Background(), manifest, filepath.Join(t.TempDir(), "runtime-bundle.toml"), filepath.Join(t.TempDir(), "runtime")) - if err == nil || !strings.Contains(err.Error(), "local manifest copy") { - t.Fatalf("Bootstrap() error = %v, want local manifest guidance", err) - } -} - -func TestBootstrapRejectsMissingSHAWithArchiveGuidance(t *testing.T) { - manifest := Manifest{ - URL: "./bundle.tar.gz", - BundleRoot: "runtime", - RequiredPaths: []string{"firecracker"}, - } - err := Bootstrap(context.Background(), manifest, filepath.Join(t.TempDir(), "runtime-bundle.toml"), filepath.Join(t.TempDir(), "runtime")) - if err == nil || !strings.Contains(err.Error(), "staged or published runtime bundle archive") { - t.Fatalf("Bootstrap() error = %v, want archive guidance", err) - } -} - -func TestPackageWritesArchive(t *testing.T) { - runtimeDir := t.TempDir() - for _, rel := range []string{ - "firecracker", - "id_ed25519", - "namegen", - "banger-vsock-agent", - "customize.sh", - "packages.apt", - "rootfs-docker.ext4", - "wtf/root/boot/vmlinux-6.8.0-94-generic", - "wtf/root/boot/initrd.img-6.8.0-94-generic", - "wtf/root/lib/modules/6.8.0-94-generic", - } { - path := filepath.Join(runtimeDir, rel) - if rel == "wtf/root/lib/modules/6.8.0-94-generic" { - if err := os.MkdirAll(path, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := os.WriteFile(filepath.Join(path, "modules.dep"), []byte(rel), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - continue - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - } - manifest := Manifest{ - BundleRoot: "runtime", - BundleMeta: BundleMetadata{ - FirecrackerBin: "firecracker", - SSHKeyPath: "id_ed25519", - NamegenPath: "namegen", - CustomizeScript: "customize.sh", - VSockAgentPath: "banger-vsock-agent", - DefaultPackages: "packages.apt", - DefaultRootfs: "rootfs-docker.ext4", - DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", - DefaultInitrd: "wtf/root/boot/initrd.img-6.8.0-94-generic", - DefaultModulesDir: "wtf/root/lib/modules/6.8.0-94-generic", - }, - RequiredPaths: []string{ - "firecracker", - "id_ed25519", - "namegen", - "banger-vsock-agent", - "customize.sh", - "packages.apt", - "rootfs-docker.ext4", - "wtf/root/boot/vmlinux-6.8.0-94-generic", - "wtf/root/boot/initrd.img-6.8.0-94-generic", - "wtf/root/lib/modules/6.8.0-94-generic", - }, - } - outArchive := filepath.Join(t.TempDir(), "bundle.tar.gz") - sum, err := Package(runtimeDir, outArchive, manifest) - if err != nil { - t.Fatalf("Package: %v", err) - } - if sum == "" { - t.Fatalf("Package() returned empty checksum") - } - if _, err := os.Stat(outArchive); err != nil { - t.Fatalf("archive missing: %v", err) - } - runtimeOut := filepath.Join(t.TempDir(), "runtime") - if err := Bootstrap(context.Background(), Manifest{ - URL: outArchive, - SHA256: sum, - BundleRoot: "runtime", - RequiredPaths: manifest.RequiredPaths, - }, filepath.Join(t.TempDir(), "runtime-bundle.toml"), runtimeOut); err != nil { - t.Fatalf("Bootstrap packaged archive: %v", err) - } - if _, err := os.Stat(filepath.Join(runtimeOut, BundleMetadataFile)); err != nil { - t.Fatalf("bundle metadata missing after bootstrap: %v", err) - } - meta, err := LoadBundleMetadata(runtimeOut) - if err != nil { - t.Fatalf("LoadBundleMetadata: %v", err) - } - if meta.DefaultRootfs != manifest.BundleMeta.DefaultRootfs { - t.Fatalf("DefaultRootfs = %q, want %q", meta.DefaultRootfs, manifest.BundleMeta.DefaultRootfs) - } -} - -func TestLoadBundleMetadataRejectsMissingRequiredPath(t *testing.T) { - runtimeDir := t.TempDir() - for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-agent", "customize.sh", "packages.apt", "rootfs-docker.ext4"} { - path := filepath.Join(runtimeDir, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - } - data := mustJSON(t, BundleMetadata{ - FirecrackerBin: "firecracker", - SSHKeyPath: "id_ed25519", - NamegenPath: "namegen", - CustomizeScript: "customize.sh", - VSockAgentPath: "banger-vsock-agent", - DefaultPackages: "packages.apt", - DefaultRootfs: "rootfs-docker.ext4", - DefaultKernel: "missing-kernel", - }) - if err := os.WriteFile(filepath.Join(runtimeDir, BundleMetadataFile), []byte(data), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - if _, err := LoadBundleMetadata(runtimeDir); err == nil || !strings.Contains(err.Error(), "default_kernel") { - t.Fatalf("LoadBundleMetadata() error = %v, want default_kernel failure", err) - } -} - -func TestLoadBundleMetadataAcceptsLegacyVsockPingHelperPath(t *testing.T) { - runtimeDir := t.TempDir() - for _, rel := range []string{"firecracker", "id_ed25519", "namegen", "banger-vsock-pingd", "customize.sh", "packages.apt", "rootfs-docker.ext4", "wtf/root/boot/vmlinux-6.8.0-94-generic"} { - path := filepath.Join(runtimeDir, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - } - data := mustJSON(t, BundleMetadata{ - FirecrackerBin: "firecracker", - SSHKeyPath: "id_ed25519", - NamegenPath: "namegen", - CustomizeScript: "customize.sh", - VSockPingHelperPath: "banger-vsock-pingd", - DefaultPackages: "packages.apt", - DefaultRootfs: "rootfs-docker.ext4", - DefaultKernel: "wtf/root/boot/vmlinux-6.8.0-94-generic", - }) - if err := os.WriteFile(filepath.Join(runtimeDir, BundleMetadataFile), []byte(data), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - meta, err := LoadBundleMetadata(runtimeDir) - if err != nil { - t.Fatalf("LoadBundleMetadata: %v", err) - } - if meta.VSockAgentPath != "banger-vsock-pingd" { - t.Fatalf("VSockAgentPath = %q", meta.VSockAgentPath) - } -} - -func buildArchive(t *testing.T, files map[string]string) []byte { - t.Helper() - var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - tw := tar.NewWriter(gz) - for name, contents := range files { - header := &tar.Header{ - Name: name, - Mode: 0o644, - Size: int64(len(contents)), - } - if err := tw.WriteHeader(header); err != nil { - t.Fatalf("WriteHeader(%s): %v", name, err) - } - if _, err := tw.Write([]byte(contents)); err != nil { - t.Fatalf("Write(%s): %v", name, err) - } - } - if err := tw.Close(); err != nil { - t.Fatalf("Close tar: %v", err) - } - if err := gz.Close(); err != nil { - t.Fatalf("Close gzip: %v", err) - } - return buf.Bytes() -} - -func sha256Hex(data []byte) string { - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} - -func mustJSON(t *testing.T, value any) string { - t.Helper() - data, err := json.Marshal(value) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - return string(data) -} diff --git a/internal/store/store.go b/internal/store/store.go index f15ebfc..1ef1dca 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -120,8 +120,8 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { const query = ` INSERT INTO images ( id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, - modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + modules_dir, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, managed=excluded.managed, @@ -131,7 +131,6 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { 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, seeded_ssh_public_key_fingerprint=excluded.seeded_ssh_public_key_fingerprint, docker=excluded.docker, @@ -146,7 +145,6 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { image.KernelPath, image.InitrdPath, image.ModulesDir, - image.PackagesPath, image.BuildSize, image.SeededSSHPublicKeyFingerprint, boolToInt(image.Docker), @@ -157,15 +155,15 @@ func (s *Store) UpsertImage(ctx context.Context, image model.Image) error { } func (s *Store) GetImageByName(ctx context.Context, name string) (model.Image, error) { - return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at FROM images WHERE name = ?", name) + return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, build_size, seeded_ssh_public_key_fingerprint, 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, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at FROM images WHERE id = ?", id) + return s.getImage(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, build_size, seeded_ssh_public_key_fingerprint, 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, work_seed_path, kernel_path, initrd_path, modules_dir, packages_path, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at FROM images ORDER BY created_at ASC") + rows, err := s.db.QueryContext(ctx, "SELECT id, name, managed, artifact_dir, rootfs_path, work_seed_path, kernel_path, initrd_path, modules_dir, build_size, seeded_ssh_public_key_fingerprint, docker, created_at, updated_at FROM images ORDER BY created_at ASC") if err != nil { return nil, err } @@ -355,7 +353,6 @@ func scanImageRow(row scanner) (model.Image, error) { &image.KernelPath, &image.InitrdPath, &image.ModulesDir, - &image.PackagesPath, &image.BuildSize, &seededSSHPublicKeyFingerprint, &docker, diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0e7ea2a..164ad4e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -344,7 +344,6 @@ func sampleImage(name string) model.Image { KernelPath: "/kernels/" + name, InitrdPath: "/initrd/" + name, ModulesDir: "/modules/" + name, - PackagesPath: "/packages/" + name + ".apt", BuildSize: "8G", SeededSSHPublicKeyFingerprint: "seeded-fingerprint", Docker: true, diff --git a/internal/system/system.go b/internal/system/system.go index 753b532..fc81fd5 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -84,6 +84,10 @@ func RequireCommands(ctx context.Context, commands ...string) error { return nil } +func LookupExecutable(name string) (string, error) { + return exec.LookPath(name) +} + func WriteJSON(path string, value any) error { data, err := json.MarshalIndent(value, "", " ") if err != nil { diff --git a/internal/webui/server.go b/internal/webui/server.go index d87dccb..0199b41 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -86,7 +86,7 @@ type vmSetForm struct { type imageBuildForm struct { Name string - BaseRootfs string + FromImage string Size string KernelPath string InitrdPath string @@ -101,7 +101,6 @@ type imageRegisterForm struct { KernelPath string InitrdPath string ModulesDir string - PackagesPath string Docker bool } @@ -524,13 +523,7 @@ func (s *Server) handleImageList(w http.ResponseWriter, r *http.Request) error { } func (s *Server) handleImageBuildForm(w http.ResponseWriter, r *http.Request) error { - cfg := s.backend.Config() - return s.renderImageBuildPage(w, r, imageBuildForm{ - BaseRootfs: cfg.DefaultBaseRootfs, - KernelPath: cfg.DefaultKernel, - InitrdPath: cfg.DefaultInitrd, - ModulesDir: cfg.DefaultModulesDir, - }, "") + return s.renderImageBuildPage(w, r, imageBuildForm{}, "") } func (s *Server) renderImageBuildPage(w http.ResponseWriter, r *http.Request, form imageBuildForm, formErr string) error { @@ -566,12 +559,7 @@ func (s *Server) handleImageBuild(w http.ResponseWriter, r *http.Request) error } func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error { - cfg := s.backend.Config() - return s.renderImageRegisterPage(w, r, imageRegisterForm{ - KernelPath: cfg.DefaultKernel, - InitrdPath: cfg.DefaultInitrd, - ModulesDir: cfg.DefaultModulesDir, - }, "") + return s.renderImageRegisterPage(w, r, imageRegisterForm{}, "") } func (s *Server) renderImageRegisterPage(w http.ResponseWriter, r *http.Request, form imageRegisterForm, formErr string) error { @@ -808,9 +796,6 @@ func (s *Server) pickerRoots() []pickerRoot { if layout.StateDir != "" { roots = append(roots, pickerRoot{Label: "State", Path: layout.StateDir}) } - if runtimeDir := s.backend.Config().RuntimeDir; runtimeDir != "" { - roots = append(roots, pickerRoot{Label: "Runtime", Path: runtimeDir}) - } result := make([]pickerRoot, 0, len(roots)) for _, root := range roots { root.Path = filepath.Clean(root.Path) @@ -998,7 +983,7 @@ func (s *Server) parseImageBuildForm(r *http.Request) (imageBuildForm, api.Image } form := imageBuildForm{ Name: strings.TrimSpace(r.FormValue("name")), - BaseRootfs: strings.TrimSpace(r.FormValue("base_rootfs")), + FromImage: strings.TrimSpace(r.FormValue("from_image")), Size: strings.TrimSpace(r.FormValue("size")), KernelPath: strings.TrimSpace(r.FormValue("kernel_path")), InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")), @@ -1007,7 +992,7 @@ func (s *Server) parseImageBuildForm(r *http.Request) (imageBuildForm, api.Image } params := api.ImageBuildParams{ Name: form.Name, - BaseRootfs: form.BaseRootfs, + FromImage: form.FromImage, Size: form.Size, KernelPath: form.KernelPath, InitrdPath: form.InitrdPath, @@ -1028,7 +1013,6 @@ func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api KernelPath: strings.TrimSpace(r.FormValue("kernel_path")), InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")), ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")), - PackagesPath: strings.TrimSpace(r.FormValue("packages_path")), Docker: r.FormValue("docker") == "on", } params := api.ImageRegisterParams{ @@ -1038,7 +1022,6 @@ func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api KernelPath: form.KernelPath, InitrdPath: form.InitrdPath, ModulesDir: form.ModulesDir, - PackagesPath: form.PackagesPath, Docker: form.Docker, } return form, params, nil diff --git a/internal/webui/templates/images.html b/internal/webui/templates/images.html index 0b4fe3b..f8e884b 100644 --- a/internal/webui/templates/images.html +++ b/internal/webui/templates/images.html @@ -33,20 +33,14 @@ {{end}} {{define "image_build_content"}} -

Build a managed image from a base rootfs, then redirect into the async build progress view.

+

Build a managed image from an existing registered image, then redirect into the async build progress view.

{{if .ErrorMessage}}
{{.ErrorMessage}}
{{end}}
{{template "csrf_field" .}} - + -