Wires updater + the existing system-install helpers into a single
operator-facing flow:
1. FetchManifest, resolve target release (default: latest_stable;
override with --to vX.Y.Z).
2. --check exits with a one-line "up to date" / "update available".
Same as `banger update --check` style for tools polling on a
timer.
3. requireRoot beyond this point — we're about to write
/usr/local/bin and talk to systemctl.
4. daemon.operations.list → refuse if any operation isn't Done.
--force overrides; per the v0.1.0 plan there's no drain wait.
5. PrepareCleanStaging + DownloadRelease + StageTarball into
/var/cache/banger/updates/.
6. Sanity-run the staged binaries: `banger --version` must mention
the expected version; `bangerd --check-migrations --system`
must exit 0 (compatible) or 1 (will auto-migrate). Exit 2
(incompatible) aborts before the swap.
7. --dry-run stops here with a one-line plan, leaves staging.
8. Swap (vsock → bangerd → banger) → restart bangerd-root then
bangerd → waitForDaemonReady on the system socket.
9. Run `banger doctor` against the JUST-INSTALLED CLI binary
(not d.doctor in-process — we want to exercise the new binary
end-to-end). FAIL triggers auto-rollback: restore .previous
backups, restart services, surface the original failure with
"(rolled back to previous install)".
10. UpdateBuildInfo on /etc/banger/install.toml. CleanupBackups.
Wipe staging dir.
rollbackAndWrap / rollbackAndRestart split: the former is for
failures BEFORE the systemctl restart (old binaries are still on
disk under .previous; the OLD daemon is still running because the
restart never happened). The latter is for failures AFTER, where
rollback ALSO needs another systemctl restart so the OLD versions
take over again. If even rollback's restart fails, we surface
everything we know — the install is broken and the operator gets
the breadcrumbs to fix it manually.
Existing TestNewBangerCommandHasExpectedSubcommands updated to
include "update" in the expected ordering.
Live exercise against the empty bucket today errors as expected:
$ banger update --check
banger: discover: fetch manifest: HTTP 404 Not Found # exit 1
once the user publishes the first manifest the same command will
report "up to date" or "update available".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
5.7 KiB
Go
207 lines
5.7 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
|
|
|
|
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.
|
|
|
|
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 {
|
|
return &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
|
|
|
|
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); err != nil {
|
|
return err
|
|
}
|
|
if report.HasFailures() {
|
|
return errors.New("doctor found failing checks")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
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(
|
|
¶ms.RootfsPath,
|
|
¶ms.WorkSeedPath,
|
|
¶ms.KernelPath,
|
|
¶ms.InitrdPath,
|
|
¶ms.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)
|
|
}
|