package cmd import ( "log/slog" "github.com/portainer/kubesolo-os/update/pkg/bootenv" "github.com/portainer/kubesolo-os/update/pkg/config" "github.com/portainer/kubesolo-os/update/pkg/state" ) // opts holds shared command-line options for all subcommands. type opts struct { ServerURL string GrubenvPath string TimeoutSecs int PubKeyPath string BootEnvType string // "grub" or "rpi" BootEnvPath string // path for RPi boot control dir StatePath string // location of state.json (default: state.DefaultPath) ConfPath string // location of update.conf (default: config.DefaultPath) Channel string // update channel ("stable" by default) MaintenanceWindow string // "HH:MM-HH:MM" or empty for always-allow Force bool // bypass maintenance window JSON bool // status: emit JSON instead of human-readable } // NewBootEnv creates a BootEnv from the parsed options. func (o opts) NewBootEnv() bootenv.BootEnv { switch o.BootEnvType { case "rpi": return bootenv.NewRPi(o.BootEnvPath) default: return bootenv.NewGRUB(o.GrubenvPath) } } // parseOpts extracts command-line flags from args. // // Precedence: explicit CLI flags > /etc/kubesolo/update.conf > package // defaults. The config file is loaded first so any CLI flag overrides it. // // Unknown flags are ignored (forward-compat). func parseOpts(args []string) opts { o := opts{ GrubenvPath: "/boot/grub/grubenv", TimeoutSecs: 120, BootEnvType: "grub", StatePath: state.DefaultPath, ConfPath: config.DefaultPath, Channel: "stable", } // First pass: pick up --conf so it can point at a different file before // we load. (Tests pass --conf /update.conf.) for i := 0; i < len(args); i++ { if args[i] == "--conf" && i+1 < len(args) { o.ConfPath = args[i+1] } } // Load config file. Missing file is fine (fresh system, no cloud-init yet). if cfg, err := config.Load(o.ConfPath); err == nil && cfg != nil { if cfg.Server != "" { o.ServerURL = cfg.Server } if cfg.Channel != "" { o.Channel = cfg.Channel } if cfg.MaintenanceWindow != "" { o.MaintenanceWindow = cfg.MaintenanceWindow } if cfg.PubKey != "" { o.PubKeyPath = cfg.PubKey } } else if err != nil { slog.Warn("could not load update.conf", "path", o.ConfPath, "error", err) } // Second pass: CLI overrides config file values. for i := 0; i < len(args); i++ { switch args[i] { case "--conf": i++ // already handled above case "--state": if i+1 < len(args) { o.StatePath = args[i+1] i++ } case "--channel": if i+1 < len(args) { o.Channel = args[i+1] i++ } case "--maintenance-window": if i+1 < len(args) { o.MaintenanceWindow = args[i+1] i++ } case "--force": o.Force = true case "--json": o.JSON = true case "--server": if i+1 < len(args) { o.ServerURL = args[i+1] i++ } case "--grubenv": if i+1 < len(args) { o.GrubenvPath = args[i+1] i++ } case "--timeout": if i+1 < len(args) { val := 0 for _, c := range args[i+1] { if c >= '0' && c <= '9' { val = val*10 + int(c-'0') } } if val > 0 { o.TimeoutSecs = val } i++ } case "--pubkey": if i+1 < len(args) { o.PubKeyPath = args[i+1] i++ } case "--bootenv": if i+1 < len(args) { o.BootEnvType = args[i+1] i++ } case "--bootenv-path": if i+1 < len(args) { o.BootEnvPath = args[i+1] i++ } } } return o }