Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions go/understackctl/cmd/nautobotOp/nautobotOp.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions go/understackctl/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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())
Expand Down
6 changes: 3 additions & 3 deletions go/understackctl/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions go/understackctl/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
Loading