diff --git a/go/understackctl/cmd/nautobotOp/nautobotOp.go b/go/understackctl/cmd/nautobotOp/nautobotOp.go new file mode 100644 index 000000000..f3b8ee4b9 --- /dev/null +++ b/go/understackctl/cmd/nautobotOp/nautobotOp.go @@ -0,0 +1,272 @@ +package nautobotOp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/tools/clientcmd" +) + +var ( + nautobotGVR = schema.GroupVersionResource{ + Group: "sync.rax.io", + Version: "v1alpha1", + Resource: "nautobots", + } + + deploymentGVR = schema.GroupVersionResource{ + Group: "apps", + Version: "v1", + Resource: "deployments", + } + + stdinReader = bufio.NewReader(os.Stdin) +) + +type resyncOpts struct { + crName string + operatorName string +} + +type namespacedName struct { + Namespace string + Name string +} + +func (n namespacedName) String() string { return n.Namespace + "/" + n.Name } + +func NewCmdNautobotOp() *cobra.Command { + cmd := &cobra.Command{ + Use: "nautobotop", + Short: "Nautobot Operator operations", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newResyncCmd()) + return cmd +} + +func newResyncCmd() *cobra.Command { + opts := &resyncOpts{} + cmd := &cobra.Command{ + Use: "resync", + Short: "Force re-sync the Nautobot CRD resource", + RunE: func(cmd *cobra.Command, args []string) error { + return runResync(opts) + }, + } + cmd.Flags().StringVarP(&opts.crName, "name", "n", "", "Name of the Nautobot CR (auto-detected if omitted)") + cmd.Flags().StringVar(&opts.operatorName, "operator", "", "Operator deployment as namespace/name (auto-detected if omitted)") + return cmd +} + +func runResync(opts *resyncOpts) error { + client, err := newDynamicClient() + if err != nil { + return fmt.Errorf("creating dynamic client: %w", err) + } + + crName, err := resolveCRName(client, opts.crName) + if err != nil { + return err + } + + if err := confirmWithUser(crName); err != nil { + return err + } + + if err := clearCRStatus(client, crName); err != nil { + return err + } + + operatorRef, err := resolveOperatorDeployment(client, opts.operatorName) + if err != nil { + return err + } + + if err := rolloutRestartDeployment(client, operatorRef); err != nil { + return err + } + + log.Printf("resync complete: %s", crName) + return nil +} + +func resolveCRName(client dynamic.Interface, explicit string) (string, error) { + if explicit != "" { + return explicit, nil + } + + list, err := client.Resource(nautobotGVR).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("listing nautobot CRs: %w", err) + } + + names := make([]string, len(list.Items)) + for i, item := range list.Items { + names[i] = item.GetName() + } + + selected, err := pickOne("nautobot CR", names) + if err != nil { + return "", err + } + log.Printf("using nautobot CR: %s", selected) + return selected, nil +} + +func resolveOperatorDeployment(client dynamic.Interface, explicit string) (namespacedName, error) { + if explicit != "" { + ref, err := parseNamespacedName(explicit) + if err != nil { + return namespacedName{}, fmt.Errorf("invalid --operator value: %w", err) + } + return ref, nil + } + + list, err := client.Resource(deploymentGVR).Namespace("").List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return namespacedName{}, fmt.Errorf("listing deployments: %w", err) + } + + var candidates []namespacedName + for _, item := range list.Items { + if strings.Contains(item.GetName(), "nautobotop") { + candidates = append(candidates, namespacedName{ + Namespace: item.GetNamespace(), + Name: item.GetName(), + }) + } + } + + labels := make([]string, len(candidates)) + for i, c := range candidates { + labels[i] = c.String() + } + + selected, err := pickOne("operator deployment", labels) + if err != nil { + return namespacedName{}, err + } + + ref, _ := parseNamespacedName(selected) + log.Printf("using operator deployment: %s", ref) + return ref, nil +} + +func parseNamespacedName(s string) (namespacedName, error) { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return namespacedName{}, fmt.Errorf("expected namespace/name format, got %q", s) + } + return namespacedName{Namespace: parts[0], Name: parts[1]}, nil +} + +func clearCRStatus(client dynamic.Interface, crName string) error { + log.Printf("clearing status: %s", crName) + + cr, err := client.Resource(nautobotGVR).Get(context.TODO(), crName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting nautobot %q: %w", crName, err) + } + + cr.Object["status"] = map[string]any{} + + if _, err = client.Resource(nautobotGVR).UpdateStatus(context.TODO(), cr, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("clearing status on %q: %w", crName, err) + } + return nil +} + +func rolloutRestartDeployment(client dynamic.Interface, ref namespacedName) error { + log.Printf("restarting deployment: %s", ref) + + patch, err := json.Marshal(map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "understackctl/restartedAt": time.Now().Format(time.RFC3339), + }, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("marshalling restart patch: %w", err) + } + + if _, err = client.Resource(deploymentGVR).Namespace(ref.Namespace).Patch( + context.TODO(), ref.Name, types.MergePatchType, patch, metav1.PatchOptions{}, + ); err != nil { + return fmt.Errorf("restarting deployment %s: %w", ref, err) + } + return nil +} + +func confirmWithUser(crName string) error { + raw, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), nil, + ).RawConfig() + if err != nil { + return fmt.Errorf("loading kubeconfig: %w", err) + } + + fmt.Printf("\n Cluster: %s\n Resource: %s\n\n", raw.CurrentContext, crName) + fmt.Print(" Proceed with resync? [y/N]: ") + + line, err := stdinReader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + if strings.TrimSpace(strings.ToLower(line)) != "y" { + return fmt.Errorf("aborted") + } + return nil +} + +func pickOne(kind string, items []string) (string, error) { + switch len(items) { + case 0: + return "", fmt.Errorf("no %s found in cluster", kind) + case 1: + return items[0], nil + } + + fmt.Printf("Multiple %s resources found:\n", kind) + for i, item := range items { + fmt.Printf(" [%d] %s\n", i+1, item) + } + fmt.Print("Select number: ") + + line, err := stdinReader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("reading input: %w", err) + } + + var choice int + if _, err := fmt.Sscanf(strings.TrimSpace(line), "%d", &choice); err != nil || choice < 1 || choice > len(items) { + return "", fmt.Errorf("invalid selection: must be 1-%d", len(items)) + } + return items[choice-1], nil +} + +func newDynamicClient() (dynamic.Interface, error) { + cfg, err := clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile) + if err != nil { + return nil, fmt.Errorf("loading kubeconfig: %w", err) + } + return dynamic.NewForConfig(cfg) +} diff --git a/go/understackctl/cmd/root/root.go b/go/understackctl/cmd/root/root.go index 6163ce821..18b5848d0 100644 --- a/go/understackctl/cmd/root/root.go +++ b/go/understackctl/cmd/root/root.go @@ -10,6 +10,7 @@ import ( "github.com/rackerlabs/understack/go/understackctl/cmd/dex" "github.com/rackerlabs/understack/go/understackctl/cmd/flavor" "github.com/rackerlabs/understack/go/understackctl/cmd/helmConfig" + "github.com/rackerlabs/understack/go/understackctl/cmd/nautobotOp" "github.com/rackerlabs/understack/go/understackctl/cmd/node" "github.com/rackerlabs/understack/go/understackctl/cmd/openstack" "github.com/rackerlabs/understack/go/understackctl/cmd/other" @@ -35,6 +36,7 @@ func init() { rootCmd.AddCommand(dex.NewCmdDexSecrets()) rootCmd.AddCommand(flavor.NewCmdFlavor()) rootCmd.AddCommand(helmConfig.NewCmdHelmConfig()) + rootCmd.AddCommand(nautobotOp.NewCmdNautobotOp()) rootCmd.AddCommand(node.NewCmdNode()) rootCmd.AddCommand(openstack.NewCmdOpenstackSecrets()) rootCmd.AddCommand(quickstart.NewCmdQuickStart()) diff --git a/go/understackctl/go.mod b/go/understackctl/go.mod index 300e89bfb..e9f7d7eea 100644 --- a/go/understackctl/go.mod +++ b/go/understackctl/go.mod @@ -24,8 +24,8 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -89,7 +89,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.29.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.8.0 // indirect google.golang.org/protobuf v1.36.3 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/go/understackctl/go.sum b/go/understackctl/go.sum index 8f45d385d..bee53a649 100644 --- a/go/understackctl/go.sum +++ b/go/understackctl/go.sum @@ -14,10 +14,10 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= @@ -222,8 +222,8 @@ golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=