banger/internal/cli/banger.go
2026-05-01 19:34:44 -03:00

216 lines
6.4 KiB
Go

package cli
import (
"errors"
"fmt"
"path/filepath"
"strings"
"banger/internal/api"
"banger/internal/buildinfo"
"github.com/spf13/cobra"
)
// NewBangerCommand builds the top-level cobra tree with production
// defaults wired into the dependency struct. Tests reach into the
// package directly — see newRootCommand + defaultDeps.
func NewBangerCommand() *cobra.Command {
return defaultDeps().newRootCommand()
}
func (d *deps) newRootCommand() *cobra.Command {
root := &cobra.Command{
Use: "banger",
Version: formatVersionLine(buildinfo.Current()),
Short: "Run development sandboxes as Firecracker microVMs",
Long: strings.TrimSpace(`
banger runs disposable development sandboxes as Firecracker microVMs.
Each sandbox boots in a few seconds, gets its own root filesystem and
network, and exits on demand.
The most common workflow is one command:
banger vm run bare sandbox, drops into ssh
banger vm run ./repo ships a repo into /root/repo, drops into ssh
banger vm run ./repo -- make test ships a repo, runs the command, exits with its status
banger vm run --rm -- script.sh --rm: VM auto-deletes when the session/command exits
banger vm run --nat ./repo --nat: outbound internet (required when .mise.toml installs tools)
banger vm run -d ./repo --nat -d/--detach: prep workspace + bootstrap, exit without ssh
For a longer-lived VM, use 'banger vm create' to provision and
'banger vm ssh <name>' to attach. 'banger ps' lists running VMs;
'banger vm list --all' shows stopped ones too. Guests are reachable
at <name>.vm from the host once 'banger ssh-config --install' is run.
First-time setup, in order:
sudo banger system install install the systemd services
banger doctor confirm the host is ready
banger image pull debian-bookworm fetch a default image
Run 'banger <command> --help' for any subcommand. Run 'banger doctor'
to diagnose host readiness problems.
`),
SilenceUsage: true,
SilenceErrors: true,
RunE: helpNoArgs,
}
// Drop cobra's default "{{.Name}} version {{.Version}}" wrapper —
// our Version string is already a complete sentence.
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(
d.newDaemonCommand(),
d.newDoctorCommand(),
d.newImageCommand(),
d.newInternalCommand(),
d.newKernelCommand(),
newSSHConfigCommand(),
d.newSystemCommand(),
d.newUpdateCommand(),
newVersionCommand(),
d.newPSCommand(),
d.newVMCommand(),
)
return root
}
func (d *deps) newDoctorCommand() *cobra.Command {
var verbose bool
cmd := &cobra.Command{
Use: "doctor",
Short: "Check host and runtime readiness",
Long: strings.TrimSpace(`
Check that the host has everything banger needs to boot guests:
required tools (mkfs.ext4, debugfs, dmsetup, ip, iptables, ...), KVM
access, daemon reachability, and per-feature preflight (NAT, DNS
routing, work-disk seeding).
Run 'banger doctor':
- after 'banger system install' to confirm the install took
- after upgrading the host kernel or banger itself
- when 'banger vm run' fails with an unclear error
By default, prints failing and warning checks only and a summary
footer; a healthy host collapses to a single line. Pass --verbose to
print every check with its details. Exit code is non-zero if any
check fails. Warnings are reported but do not fail the run.
`),
Args: noArgsUsage("usage: banger doctor"),
RunE: func(cmd *cobra.Command, args []string) error {
report, err := d.doctor(cmd.Context())
if err != nil {
return err
}
if err := printDoctorReport(cmd.OutOrStdout(), report, verbose); err != nil {
return err
}
if report.HasFailures() {
return errors.New("doctor found failing checks")
}
return nil
},
}
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show every check (default: only failures and warnings)")
return cmd
}
func newVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Show banger build information",
Args: noArgsUsage("usage: banger version"),
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), formatBuildInfoBlock(buildinfo.Current()))
return err
},
}
}
func helpNoArgs(cmd *cobra.Command, args []string) error {
if len(args) != 0 {
return fmt.Errorf("unknown arguments: %s", strings.Join(args, " "))
}
return cmd.Help()
}
func noArgsUsage(usage string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != 0 {
return errors.New(usage)
}
return nil
}
}
func exactArgsUsage(n int, usage string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != n {
return errors.New(usage)
}
return nil
}
}
func minArgsUsage(n int, usage string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) < n {
return errors.New(usage)
}
return nil
}
}
func maxArgsUsage(n int, usage string) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) > n {
return errors.New(usage)
}
return nil
}
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error {
return absolutizePaths(
&params.RootfsPath,
&params.WorkSeedPath,
&params.KernelPath,
&params.InitrdPath,
&params.ModulesDir,
)
}
func absolutizePaths(values ...*string) error {
var err error
for _, value := range values {
if *value == "" || filepath.IsAbs(*value) {
continue
}
*value, err = filepath.Abs(*value)
if err != nil {
return err
}
}
return nil
}
func formatBuildInfoBlock(info buildinfo.Info) string {
return fmt.Sprintf("version: %s\ncommit: %s\nbuilt_at: %s\n", info.Version, info.Commit, info.BuiltAt)
}
// formatVersionLine renders a buildinfo.Info as a single line —
// "banger v0.1.0 (commit abcd1234, built 2026-04-28T20:45:50Z)" — for
// the `--version` flag. Long commit strings are truncated to the
// first 8 hex chars so the line stays scannable. The verbose
// multi-line form lives on `banger version` for callers that want
// the full SHA / built_at on separate lines.
func formatVersionLine(info buildinfo.Info) string {
commit := info.Commit
if len(commit) > 8 {
commit = commit[:8]
}
return fmt.Sprintf("banger %s (commit %s, built %s)", info.Version, commit, info.BuiltAt)
}