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 Registry string // OCI registry ref (e.g. ghcr.io/foo/kubesolo-os). Mutually exclusive with ServerURL. Tag string // OCI tag to pull (default: equal to Channel, falling back to "stable") 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 HealthcheckURL string // optional GET probe for healthcheck AutoRollbackAfter int // healthcheck: rollback after N consecutive failures (0=off) KubeSystemSettle int // healthcheck: kube-system pods must be Running for N seconds (0=disabled) 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 } if cfg.HealthcheckURL != "" { o.HealthcheckURL = cfg.HealthcheckURL } if cfg.AutoRollbackAfter > 0 { o.AutoRollbackAfter = cfg.AutoRollbackAfter } } 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 "--healthcheck-url": if i+1 < len(args) { o.HealthcheckURL = args[i+1] i++ } case "--auto-rollback-after": if i+1 < len(args) { n := 0 for _, ch := range args[i+1] { if ch >= '0' && ch <= '9' { n = n*10 + int(ch-'0') } else { n = 0 break } } if n > 0 { o.AutoRollbackAfter = n } i++ } case "--kube-system-settle": if i+1 < len(args) { n := 0 for _, ch := range args[i+1] { if ch >= '0' && ch <= '9' { n = n*10 + int(ch-'0') } else { n = 0 break } } if n > 0 { o.KubeSystemSettle = n } i++ } case "--json": o.JSON = true case "--server": if i+1 < len(args) { o.ServerURL = args[i+1] i++ } case "--registry": if i+1 < len(args) { o.Registry = args[i+1] i++ } case "--tag": if i+1 < len(args) { o.Tag = 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 }