package cli import ( "context" "errors" "fmt" "log/slog" "time" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/engine" "git.cloudinit.dev/coreci/orca/internal/store" ) var cutoverTimeout time.Duration var clusterCutoverCmd = &cobra.Command{ Use: "cutover", Short: "Stop v0.8 orca daemons and adopt running allocs (P14b)", Long: `Stop the v0.8 orca-daemon on every peer that still runs one, discover its running allocations (orca-alloc-*.service), and adopt each into the SSH-push path (mark it managed by the CLI-side scheduler). The allocation's systemd unit keeps running independently of the daemon; the cutover only re-records ownership in the cluster store and stops the daemon. Idempotent: a peer whose daemon is already stopped is a no-op for that peer. Re-adopting an already-adopted alloc is a no-op.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runCutover(cmd) }, } func runCutover(cmd *cobra.Command) error { ctx, cancel := context.WithTimeout(cmd.Context(), cutoverTimeout) defer cancel() reg, closer, err := nodeRegistry() if err != nil { return err } defer closer() nodes, err := reg.List(ctx) if err != nil { return fmt.Errorf("list nodes: %w", err) } ex, err := drainExecFromCtx(cmd.Context()) if err != nil { return fmt.Errorf("ssh transport: %w", err) } log := newLogger() type peerResult struct { Node string `json:"node"` Peer string `json:"peer"` DaemonStopped bool `json:"daemon_stopped"` AlreadyStopped bool `json:"already_stopped"` Adopted []string `json:"adopted"` Failed string `json:"failed,omitempty"` } results := make([]peerResult, 0, len(nodes)) var stopped, already, failed, adopted []string for i := range nodes { n := nodes[i] peer := peerAddrForNode(n) if peer == "" { continue } pr := peerResult{Node: n.Name, Peer: peer, Adopted: []string{}} stopCmd := "systemctl stop orca-daemon.service" _, stopErr := ex.Exec(ctx, peer, stopCmd) switch { case stopErr == nil: pr.DaemonStopped = true stopped = append(stopped, n.Name) default: var exitErr *sshExitErr if errors.As(stopErr, &exitErr) && exitErr.code == 5 { pr.AlreadyStopped = true already = append(already, n.Name) } else { pr.Failed = stopErr.Error() failed = append(failed, n.Name) results = append(results, pr) log.Warn("cutover: stop daemon failed", slog.String("node", n.Name), slog.String("peer", peer), "error", stopErr) continue } } ids, listErr := listRunningAllocs(ctx, ex, peer) if listErr != nil { pr.Failed = listErr.Error() failed = append(failed, n.Name) results = append(results, pr) log.Warn("cutover: list allocs failed", slog.String("node", n.Name), slog.String("peer", peer), "error", listErr) continue } for _, id := range ids { if err := adoptAlloc(ctx, n.ID, id); err != nil { log.Warn("cutover: adopt alloc failed", slog.String("alloc", id), slog.String("node", n.Name), "error", err) pr.Failed = fmt.Sprintf("%sadopt %s: %v", pr.Failed, id, err) continue } pr.Adopted = append(pr.Adopted, id) adopted = append(adopted, n.Name+"/"+id) } results = append(results, pr) } summary := map[string]any{ "stopped": stopped, "already_stopped": already, "failed": failed, "adopted": adopted, "per_node": results, } if db, dbErr := store.Open(certpaths.DBPath()); dbErr == nil { engine.NewAudit(store.NewAuditRepo(db), log).Record(ctx, "cli", "cluster.cutover", "cluster", "success", nil, summary) db.Close() } if jsonOutput { return printJSON(summary) } out := cmd.OutOrStdout() fmt.Fprintf(out, "✓ cutover complete (%d stopped, %d already stopped, %d failed, %d adopted)\n", len(stopped), len(already), len(failed), len(adopted)) for _, n := range stopped { fmt.Fprintf(out, " stopped %s\n", n) } for _, n := range already { fmt.Fprintf(out, " already-stopped %s\n", n) } for _, a := range adopted { fmt.Fprintf(out, " adopted %s\n", a) } for _, n := range failed { fmt.Fprintf(out, " failed %s\n", n) } if len(failed) > 0 { return fmt.Errorf("cutover: %d peer(s) failed", len(failed)) } return nil } func adoptAlloc(ctx context.Context, nodeID, allocID string) error { db, err := store.Open(certpaths.DBPath()) if err != nil { return fmt.Errorf("open db: %w", err) } defer db.Close() hist := store.NewAllocHistoryRepo(db) if err := hist.EnsureSchema(ctx); err != nil { return fmt.Errorf("alloc history schema: %w", err) } entry := store.AllocHistoryEntry{ AllocID: allocID, NodeID: nodeID, FromState: "daemon-managed", ToState: "ssh-push-managed", Timestamp: time.Now().UTC(), Reason: "p14b-cutover", } return hist.Record(ctx, entry) } func init() { clusterCutoverCmd.Flags().DurationVar(&cutoverTimeout, "timeout", 5*time.Minute, "max time for the full cutover across all peers") }