package cli import ( "errors" "fmt" "os" "strings" "banger/internal/buildinfo" "banger/internal/daemon" "banger/internal/paths" "banger/internal/roothelper" "banger/internal/store" "github.com/spf13/cobra" ) // bangerdExit is var-injected so tests can capture the exit code // without terminating the test process. Production points at os.Exit. var bangerdExit = os.Exit func NewBangerdCommand() *cobra.Command { var systemMode bool var rootHelperMode bool var checkMigrations bool cmd := &cobra.Command{ Use: "bangerd", Version: strings.Replace(formatVersionLine(buildinfo.Current()), "banger ", "bangerd ", 1), Short: "Run the banger daemon", SilenceUsage: true, SilenceErrors: true, Args: noArgsUsage("usage: bangerd"), RunE: func(cmd *cobra.Command, args []string) error { if systemMode && rootHelperMode { return errors.New("choose only one of --system or --root-helper") } if checkMigrations { return runCheckMigrations(cmd, systemMode) } if rootHelperMode { server, err := roothelper.Open() if err != nil { return err } defer server.Close() return server.Serve(cmd.Context()) } open := daemon.Open if systemMode { open = daemon.OpenSystem } d, err := open(cmd.Context()) if err != nil { return err } defer d.Close() return d.Serve(cmd.Context()) }, } cmd.Flags().BoolVar(&systemMode, "system", false, "run as the owner-user system service") cmd.Flags().BoolVar(&rootHelperMode, "root-helper", false, "run as the privileged root helper service") cmd.Flags().BoolVar(&checkMigrations, "check-migrations", false, "inspect the state DB and report whether this binary's schema matches; exit 0=compatible, 1=migrations needed, 2=incompatible") cmd.SetVersionTemplate("{{.Version}}\n") cmd.CompletionOptions.DisableDefaultCmd = true return cmd } // runCheckMigrations is the entry point for `bangerd --check-migrations`. // Used by `banger update` to gate a binary swap on a staged binary // before service restart: if the staged binary doesn't recognise the // running install's schema, the swap is aborted before any host state // changes. // // Exit codes are part of the contract: // // 0 — compatible (no migrations to apply on Open) // 1 — migrations needed (binary newer than DB; safe to swap) // 2 — incompatible (DB has migrations this binary doesn't know; // swapping would leave the daemon unable to open the store) func runCheckMigrations(cmd *cobra.Command, systemMode bool) error { layout := paths.ResolveSystem() if !systemMode { userLayout, err := paths.Resolve() if err != nil { return err } layout = userLayout } state, err := store.InspectSchemaState(layout.DBPath) if err != nil { return fmt.Errorf("inspect %s: %w", layout.DBPath, err) } out := cmd.OutOrStdout() switch state.Compatibility { case store.SchemaCompatible: fmt.Fprintf(out, "compatible: db at v%d, binary knows up to v%d\n", lastID(state.AppliedIDs), state.KnownMaxID) return nil case store.SchemaMigrationsNeeded: fmt.Fprintf(out, "migrations needed: pending %v (binary will apply on first Open)\n", state.Pending) // Distinct exit code so callers can tell "safe to swap, will // auto-migrate" apart from "compatible, no work pending". // Returning a cobra error would also exit non-zero, but we // want a specific code (1) — and we don't want SilenceErrors // to print our message twice. bangerdExit(1) return nil case store.SchemaIncompatible: fmt.Fprintf(out, "incompatible: db has unknown migrations %v (binary knows up to v%d)\n", state.Unknown, state.KnownMaxID) bangerdExit(2) return nil default: return fmt.Errorf("unexpected schema-state classification %d", state.Compatibility) } } // lastID returns the largest int in xs, or 0 when empty. The schema- // migrations table doesn't guarantee insert order, so we scan rather // than trusting xs[len-1]. func lastID(xs []int) int { max := 0 for _, x := range xs { if x > max { max = x } } return max }