diff --git a/README.md b/README.md index ce5d17f..e26c334 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ commands you run: |---|---| | `helm` (3.8+) | `deploy render` | | `git` | `deploy init`, `deploy render` | -| `argo` | `node enroll-server`, `node inspect-server` | +| `argo` | `workflow enroll-server`, `workflow enroll-netdev`, `workflow enroll-fw`, `workflow inspect-server`, `workflow list` | | `kubectl` / a valid kubeconfig | anything that talks to a cluster | ## Development diff --git a/cmd/node/inspect.go b/cmd/node/inspect.go deleted file mode 100644 index 3bc6550..0000000 --- a/cmd/node/inspect.go +++ /dev/null @@ -1,69 +0,0 @@ -package node - -import ( - "fmt" - "os" - "os/exec" - - "github.com/spf13/cobra" -) - -const inspectWorkflowTemplate = "wftmpl/inspect-server" - -type inspectOptions struct { - Logs bool -} - -func newCmdInspectServer() *cobra.Command { - opts := &inspectOptions{} - - cmd := &cobra.Command{ - Use: "inspect-server ", - Short: "Submit the inspect-server Argo workflow for a node", - Args: cobra.ExactArgs(1), - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - nodeID := args[0] - - argoArgs := buildInspectArgoArgs(nodeID, opts) - - currentContext, err := currentKubeContext() - if err != nil { - return fmt.Errorf("get current Kubernetes context: %w", err) - } - - cmd.Printf("Kubernetes context: %s\n", currentContext) - cmd.Printf("Running: %s\n", shellQuoteCommand("argo", argoArgs)) - - argoCmd := exec.Command("argo", argoArgs...) - argoCmd.Stdin = os.Stdin - argoCmd.Stdout = cmd.OutOrStdout() - argoCmd.Stderr = cmd.ErrOrStderr() - - if err := argoCmd.Run(); err != nil { - return fmt.Errorf("run argo inspect workflow: %w", err) - } - - return nil - }, - } - - cmd.Flags().BoolVar(&opts.Logs, "log", false, "Stream workflow logs after submission") - - return cmd -} - -func buildInspectArgoArgs(nodeID string, opts *inspectOptions) []string { - args := []string{ - "-n", enrollWorkflowNamespace, - "submit", - "--from", inspectWorkflowTemplate, - "-p", fmt.Sprintf("node=%s", nodeID), - } - - if opts.Logs { - args = append(args, "--log") - } - - return args -} diff --git a/cmd/node/inspect_test.go b/cmd/node/inspect_test.go deleted file mode 100644 index 83a8a84..0000000 --- a/cmd/node/inspect_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package node - -import ( - "reflect" - "testing" -) - -func TestBuildInspectArgoArgs(t *testing.T) { - args := buildInspectArgoArgs("node-123", &inspectOptions{}) - - expected := []string{ - "-n", enrollWorkflowNamespace, - "submit", - "--from", inspectWorkflowTemplate, - "-p", "node=node-123", - } - if !reflect.DeepEqual(args, expected) { - t.Fatalf("unexpected args: got %#v want %#v", args, expected) - } -} - -func TestBuildInspectArgoArgsWithLogs(t *testing.T) { - args := buildInspectArgoArgs("node-123", &inspectOptions{Logs: true}) - - expected := []string{ - "-n", enrollWorkflowNamespace, - "submit", - "--from", inspectWorkflowTemplate, - "-p", "node=node-123", - "--log", - } - if !reflect.DeepEqual(args, expected) { - t.Fatalf("unexpected args: got %#v want %#v", args, expected) - } -} diff --git a/cmd/node/node.go b/cmd/node/node.go deleted file mode 100644 index 6e5e436..0000000 --- a/cmd/node/node.go +++ /dev/null @@ -1,16 +0,0 @@ -package node - -import "github.com/spf13/cobra" - -func NewCmdNode() *cobra.Command { - cmd := &cobra.Command{ - Use: "node", - Short: "Manage node lifecycle workflows", - Long: "Manage node lifecycle workflows by wrapping Argo workflows and related platform APIs", - } - - cmd.AddCommand(newCmdEnrollServer()) - cmd.AddCommand(newCmdInspectServer()) - - return cmd -} diff --git a/cmd/root/root.go b/cmd/root/root.go index f858c12..da95d0f 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -7,8 +7,8 @@ import ( "github.com/rackerlabs/understackctl/cmd/deviceType" "github.com/rackerlabs/understackctl/cmd/flavor" "github.com/rackerlabs/understackctl/cmd/nautobotOp" - "github.com/rackerlabs/understackctl/cmd/node" cmdversion "github.com/rackerlabs/understackctl/cmd/version" + "github.com/rackerlabs/understackctl/cmd/workflow" "github.com/rackerlabs/understackctl/internal/version" "github.com/spf13/cobra" ) @@ -29,7 +29,7 @@ func init() { rootCmd.AddCommand(deviceType.NewCmdDeviceType()) rootCmd.AddCommand(flavor.NewCmdFlavor()) rootCmd.AddCommand(nautobotOp.NewCmdNautobotOp()) - rootCmd.AddCommand(node.NewCmdNode()) + rootCmd.AddCommand(workflow.NewCmdWorkflow()) rootCmd.AddCommand(cmdversion.NewCmdVersion()) } diff --git a/cmd/node/enroll.go b/cmd/workflow/enroll.go similarity index 66% rename from cmd/node/enroll.go rename to cmd/workflow/enroll.go index 6829a58..085a9e8 100644 --- a/cmd/node/enroll.go +++ b/cmd/workflow/enroll.go @@ -1,10 +1,8 @@ -package node +package workflow import ( "fmt" "net" - "os" - "os/exec" "strings" "github.com/spf13/cobra" @@ -43,24 +41,7 @@ func newCmdEnrollServer() *cobra.Command { return err } - currentContext, err := currentKubeContext() - if err != nil { - return fmt.Errorf("get current Kubernetes context: %w", err) - } - - cmd.Printf("Kubernetes context: %s\n", currentContext) - cmd.Printf("Running: %s\n", shellQuoteCommand("argo", argoArgs)) - - argoCmd := exec.Command("argo", argoArgs...) - argoCmd.Stdin = os.Stdin - argoCmd.Stdout = cmd.OutOrStdout() - argoCmd.Stderr = cmd.ErrOrStderr() - - if err := argoCmd.Run(); err != nil { - return fmt.Errorf("run argo enroll workflow: %w", err) - } - - return nil + return submitWorkflow(cmd, argoArgs, ipAddress, opts.Logs) }, } @@ -74,34 +55,25 @@ func newCmdEnrollServer() *cobra.Command { } func buildEnrollArgoArgs(cmd *cobra.Command, ipAddress string, opts *enrollOptions) ([]string, error) { - args := []string{ - "-n", enrollWorkflowNamespace, - "submit", - "--from", enrollWorkflowTemplate, - "-p", fmt.Sprintf("ip_address=%s", ipAddress), - } - - if opts.Logs { - args = append(args, "--log") - } + params := []string{fmt.Sprintf("ip_address=%s", ipAddress)} if cmd.Flags().Changed("old-password") { - args = append(args, "-p", fmt.Sprintf("old_password=%s", opts.OldPassword)) + params = append(params, fmt.Sprintf("old_password=%s", opts.OldPassword)) } if cmd.Flags().Changed("firmware-update") { - args = append(args, "-p", fmt.Sprintf("firmware_update=%t", opts.FirmwareUpdate)) + params = append(params, fmt.Sprintf("firmware_update=%t", opts.FirmwareUpdate)) } if cmd.Flags().Changed("raid-configure") { - args = append(args, "-p", fmt.Sprintf("raid_configure=%t", opts.RaidConfigure)) + params = append(params, fmt.Sprintf("raid_configure=%t", opts.RaidConfigure)) } if cmd.Flags().Changed("external-cmdb-id") { - args = append(args, "-p", fmt.Sprintf("external_cmdb_id=%s", opts.ExternalCMDBID)) + params = append(params, fmt.Sprintf("external_cmdb_id=%s", opts.ExternalCMDBID)) } - return args, nil + return buildSubmitArgs(enrollWorkflowTemplate, params...), nil } func currentKubeContext() (string, error) { @@ -117,9 +89,28 @@ func currentKubeContext() (string, error) { return config.CurrentContext, nil } +// currentKubeServer returns the API server URL of the current context's cluster. +func currentKubeServer() (string, error) { + config, err := clientcmd.NewDefaultPathOptions().GetStartingConfig() + if err != nil { + return "", err + } + + ctx, ok := config.Contexts[config.CurrentContext] + if !ok { + return "", fmt.Errorf("current context %q not found", config.CurrentContext) + } + + cluster, ok := config.Clusters[ctx.Cluster] + if !ok { + return "", fmt.Errorf("cluster %q not found", ctx.Cluster) + } + + return cluster.Server, nil +} + func shellQuoteCommand(name string, args []string) string { - parts := make([]string, 0, len(args)+1) - parts = append(parts, shellQuote(name)) + parts := []string{shellQuote(name)} for _, arg := range args { parts = append(parts, shellQuote(arg)) } diff --git a/cmd/workflow/enroll_netdev.go b/cmd/workflow/enroll_netdev.go new file mode 100644 index 0000000..09eb997 --- /dev/null +++ b/cmd/workflow/enroll_netdev.go @@ -0,0 +1,213 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +const ( + enrollNetdevWorkflowTemplate = "wftmpl/enroll-netdev" + enrollFWWorkflowTemplate = "wftmpl/enroll-fw" +) + +// netdevOptions holds the flags shared by enroll-netdev and enroll-fw, since +// enroll-fw reuses the enroll-netdev node/port lifecycle. +type netdevOptions struct { + Name string + PhysicalNet string + Ports string + PortsFile string + ExternalCMDBID string + ResourceClass string + Logs bool +} + +type fwOptions struct { + netdevOptions + ManagementIP string + ManagementSwitch string + ManagementSwitchPort string + MateSerial string + Serial string + Vendor string + Model string + Driver string +} + +func addNetdevFlags(cmd *cobra.Command, opts *netdevOptions) { + cmd.Flags().StringVar(&opts.Name, "name", "", "Node name (required)") + cmd.Flags().StringVar(&opts.PhysicalNet, "physical-network", "", "Physical network (required)") + cmd.Flags().StringVar(&opts.Ports, "ports", "", "Ports as a JSON array") + cmd.Flags().StringVar(&opts.PortsFile, "ports-file", "", "File (YAML or JSON) containing the ports array") + cmd.Flags().StringVar(&opts.ExternalCMDBID, "external-cmdb-id", "", "External CMDB identifier") + cmd.Flags().StringVar(&opts.ResourceClass, "resource-class", "", "Ironic resource class") + cmd.Flags().BoolVar(&opts.Logs, "log", false, "Stream workflow logs after submission") + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("physical-network") +} + +func newCmdEnrollNetdev() *cobra.Command { + opts := &netdevOptions{} + + cmd := &cobra.Command{ + Use: "enroll-netdev", + Short: "Submit the enroll-netdev Argo workflow for a network device", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + argoArgs, err := buildEnrollNetdevArgoArgs(cmd, opts) + if err != nil { + return err + } + + return submitWorkflow(cmd, argoArgs, opts.Name, opts.Logs) + }, + } + + addNetdevFlags(cmd, opts) + + return cmd +} + +func newCmdEnrollFW() *cobra.Command { + opts := &fwOptions{} + + cmd := &cobra.Command{ + Use: "enroll-fw", + Short: "Submit the enroll-fw Argo workflow for a firewall", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + argoArgs, err := buildEnrollFWArgoArgs(cmd, opts) + if err != nil { + return err + } + + return submitWorkflow(cmd, argoArgs, opts.Name, opts.Logs) + }, + } + + addNetdevFlags(cmd, &opts.netdevOptions) + cmd.Flags().StringVar(&opts.ManagementIP, "management-ip", "", "Firewall management IP address") + cmd.Flags().StringVar(&opts.ManagementSwitch, "management-switch", "", "Switch FQDN the management port is on (required)") + cmd.Flags().StringVar(&opts.ManagementSwitchPort, "management-switch-port", "", "Switch port the management interface is on (required)") + cmd.Flags().StringVar(&opts.MateSerial, "mate-serial", "", "Serial number of the HA mate") + cmd.Flags().StringVar(&opts.Serial, "serial", "", "Device serial number") + cmd.Flags().StringVar(&opts.Vendor, "vendor", "", "Device vendor") + cmd.Flags().StringVar(&opts.Model, "model", "", "Device model") + cmd.Flags().StringVar(&opts.Driver, "driver", "", "Ironic hardware type") + _ = cmd.MarkFlagRequired("management-switch") + _ = cmd.MarkFlagRequired("management-switch-port") + + return cmd +} + +// resolvePorts returns the ports as a compact JSON array, from either --ports +// or --ports-file. +func resolvePorts(cmd *cobra.Command, opts *netdevOptions) (string, error) { + hasPorts := cmd.Flags().Changed("ports") + hasFile := cmd.Flags().Changed("ports-file") + + switch { + case hasPorts && hasFile: + return "", fmt.Errorf("--ports and --ports-file are mutually exclusive") + case !hasPorts && !hasFile: + return "", fmt.Errorf("one of --ports or --ports-file is required") + } + + raw := []byte(opts.Ports) + if hasFile { + var err error + raw, err = os.ReadFile(opts.PortsFile) + if err != nil { + return "", fmt.Errorf("read ports file: %w", err) + } + } + + // YAML is a superset of JSON, so this handles both. + var ports []map[string]any + if err := yaml.Unmarshal(raw, &ports); err != nil { + return "", fmt.Errorf("parse ports: %w", err) + } + if len(ports) == 0 { + return "", fmt.Errorf("ports must contain at least one port") + } + + for i, port := range ports { + for _, key := range []string{"label", "mac", "switch", "intf"} { + if v, ok := port[key]; !ok || v == "" { + return "", fmt.Errorf("port %d is missing required key %q", i, key) + } + } + } + + out, err := json.Marshal(ports) + if err != nil { + return "", fmt.Errorf("encode ports: %w", err) + } + + return string(out), nil +} + +func netdevParams(cmd *cobra.Command, opts *netdevOptions) ([]string, error) { + ports, err := resolvePorts(cmd, opts) + if err != nil { + return nil, err + } + + params := []string{ + fmt.Sprintf("name=%s", opts.Name), + fmt.Sprintf("physical_network=%s", opts.PhysicalNet), + fmt.Sprintf("ports=%s", ports), + } + + if cmd.Flags().Changed("external-cmdb-id") { + params = append(params, fmt.Sprintf("external_cmdb_id=%s", opts.ExternalCMDBID)) + } + if cmd.Flags().Changed("resource-class") { + params = append(params, fmt.Sprintf("resource_class=%s", opts.ResourceClass)) + } + + return params, nil +} + +func buildEnrollNetdevArgoArgs(cmd *cobra.Command, opts *netdevOptions) ([]string, error) { + params, err := netdevParams(cmd, opts) + if err != nil { + return nil, err + } + + return buildSubmitArgs(enrollNetdevWorkflowTemplate, params...), nil +} + +func buildEnrollFWArgoArgs(cmd *cobra.Command, opts *fwOptions) ([]string, error) { + params, err := netdevParams(cmd, &opts.netdevOptions) + if err != nil { + return nil, err + } + + params = append(params, + fmt.Sprintf("management_switch=%s", opts.ManagementSwitch), + fmt.Sprintf("management_switch_port=%s", opts.ManagementSwitchPort), + ) + + optional := []struct{ flag, param, value string }{ + {"management-ip", "management_ip", opts.ManagementIP}, + {"mate-serial", "mate_serial", opts.MateSerial}, + {"serial", "serial", opts.Serial}, + {"vendor", "vendor", opts.Vendor}, + {"model", "model", opts.Model}, + {"driver", "driver", opts.Driver}, + } + for _, o := range optional { + if cmd.Flags().Changed(o.flag) { + params = append(params, fmt.Sprintf("%s=%s", o.param, o.value)) + } + } + + return buildSubmitArgs(enrollFWWorkflowTemplate, params...), nil +} diff --git a/cmd/workflow/enroll_netdev_test.go b/cmd/workflow/enroll_netdev_test.go new file mode 100644 index 0000000..ad5d782 --- /dev/null +++ b/cmd/workflow/enroll_netdev_test.go @@ -0,0 +1,136 @@ +package workflow + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +const testPortsJSON = `[{"intf":"Ethernet1","label":"port1","mac":"aa:bb:cc:dd:ee:ff","switch":"sw1.example.com"}]` + +func TestBuildEnrollNetdevArgoArgs(t *testing.T) { + cmd := newCmdEnrollNetdev() + for k, v := range map[string]string{ + "name": "fw-a", + "physical-network": "physnet1", + "ports": testPortsJSON, + "resource-class": "generic", + } { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + + opts := &netdevOptions{Name: "fw-a", PhysicalNet: "physnet1", Ports: testPortsJSON, ResourceClass: "generic"} + args, err := buildEnrollNetdevArgoArgs(cmd, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := []string{ + "-n", enrollWorkflowNamespace, + "submit", + "--from", enrollNetdevWorkflowTemplate, + "-o", "name", + "-p", "name=fw-a", + "-p", "physical_network=physnet1", + "-p", "ports=" + testPortsJSON, + "-p", "resource_class=generic", + } + if !reflect.DeepEqual(args, expected) { + t.Fatalf("unexpected args: got %#v want %#v", args, expected) + } +} + +func TestBuildEnrollFWArgoArgs(t *testing.T) { + cmd := newCmdEnrollFW() + for k, v := range map[string]string{ + "name": "fw-a", + "physical-network": "physnet1", + "ports": testPortsJSON, + "management-switch": "sw1.example.com", + "management-switch-port": "Ethernet48", + "mate-serial": "SN2", + } { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + + opts := &fwOptions{ + netdevOptions: netdevOptions{Name: "fw-a", PhysicalNet: "physnet1", Ports: testPortsJSON}, + ManagementSwitch: "sw1.example.com", + ManagementSwitchPort: "Ethernet48", + MateSerial: "SN2", + } + args, err := buildEnrollFWArgoArgs(cmd, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := []string{ + "-n", enrollWorkflowNamespace, + "submit", + "--from", enrollFWWorkflowTemplate, + "-o", "name", + "-p", "name=fw-a", + "-p", "physical_network=physnet1", + "-p", "ports=" + testPortsJSON, + "-p", "management_switch=sw1.example.com", + "-p", "management_switch_port=Ethernet48", + "-p", "mate_serial=SN2", + } + if !reflect.DeepEqual(args, expected) { + t.Fatalf("unexpected args: got %#v want %#v", args, expected) + } +} + +func TestResolvePortsFromYAMLFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "ports.yaml") + yamlPorts := "- label: port1\n mac: aa:bb:cc:dd:ee:ff\n switch: sw1.example.com\n intf: Ethernet1\n" + if err := os.WriteFile(path, []byte(yamlPorts), 0o600); err != nil { + t.Fatal(err) + } + + cmd := newCmdEnrollNetdev() + if err := cmd.Flags().Set("ports-file", path); err != nil { + t.Fatal(err) + } + + got, err := resolvePorts(cmd, &netdevOptions{PortsFile: path}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != testPortsJSON { + t.Fatalf("got %s want %s", got, testPortsJSON) + } +} + +func TestResolvePortsErrors(t *testing.T) { + tests := map[string]struct { + flags map[string]string + opts netdevOptions + want string + }{ + "none": {want: "one of --ports or --ports-file"}, + "both": {flags: map[string]string{"ports": "[]", "ports-file": "x"}, want: "mutually exclusive"}, + "empty": {flags: map[string]string{"ports": "[]"}, opts: netdevOptions{Ports: "[]"}, want: "at least one port"}, + "missingKey": {flags: map[string]string{"ports": `[{"label":"a"}]`}, opts: netdevOptions{Ports: `[{"label":"a"}]`}, want: `missing required key "mac"`}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cmd := newCmdEnrollNetdev() + for k, v := range tc.flags { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatal(err) + } + } + _, err := resolvePorts(cmd, &tc.opts) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("got %v, want error containing %q", err, tc.want) + } + }) + } +} diff --git a/cmd/node/enroll_test.go b/cmd/workflow/enroll_test.go similarity index 97% rename from cmd/node/enroll_test.go rename to cmd/workflow/enroll_test.go index 743f660..7b538b1 100644 --- a/cmd/node/enroll_test.go +++ b/cmd/workflow/enroll_test.go @@ -1,4 +1,4 @@ -package node +package workflow import ( "reflect" @@ -17,6 +17,7 @@ func TestBuildEnrollArgoArgsRequiredOnly(t *testing.T) { "-n", enrollWorkflowNamespace, "submit", "--from", enrollWorkflowTemplate, + "-o", "name", "-p", "ip_address=10.46.6.165", } if !reflect.DeepEqual(args, expected) { @@ -59,8 +60,8 @@ func TestBuildEnrollArgoArgsOptionalFlags(t *testing.T) { "-n", enrollWorkflowNamespace, "submit", "--from", enrollWorkflowTemplate, + "-o", "name", "-p", "ip_address=10.46.6.165", - "--log", "-p", "old_password=secret", "-p", "firmware_update=true", "-p", "raid_configure=false", diff --git a/cmd/workflow/inspect.go b/cmd/workflow/inspect.go new file mode 100644 index 0000000..b51c97e --- /dev/null +++ b/cmd/workflow/inspect.go @@ -0,0 +1,39 @@ +package workflow + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +const inspectWorkflowTemplate = "wftmpl/inspect-server" + +type inspectOptions struct { + Logs bool +} + +func newCmdInspectServer() *cobra.Command { + opts := &inspectOptions{} + + cmd := &cobra.Command{ + Use: "inspect-server ", + Short: "Submit the inspect-server Argo workflow for a node", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + nodeID := args[0] + + argoArgs := buildInspectArgoArgs(nodeID) + + return submitWorkflow(cmd, argoArgs, nodeID, opts.Logs) + }, + } + + cmd.Flags().BoolVar(&opts.Logs, "log", false, "Stream workflow logs after submission") + + return cmd +} + +func buildInspectArgoArgs(nodeID string) []string { + return buildSubmitArgs(inspectWorkflowTemplate, fmt.Sprintf("node=%s", nodeID)) +} diff --git a/cmd/workflow/inspect_test.go b/cmd/workflow/inspect_test.go new file mode 100644 index 0000000..e286de6 --- /dev/null +++ b/cmd/workflow/inspect_test.go @@ -0,0 +1,21 @@ +package workflow + +import ( + "reflect" + "testing" +) + +func TestBuildInspectArgoArgs(t *testing.T) { + args := buildInspectArgoArgs("node-123") + + expected := []string{ + "-n", enrollWorkflowNamespace, + "submit", + "--from", inspectWorkflowTemplate, + "-o", "name", + "-p", "node=node-123", + } + if !reflect.DeepEqual(args, expected) { + t.Fatalf("unexpected args: got %#v want %#v", args, expected) + } +} diff --git a/cmd/workflow/list.go b/cmd/workflow/list.go new file mode 100644 index 0000000..cf6fb25 --- /dev/null +++ b/cmd/workflow/list.go @@ -0,0 +1,211 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/util/duration" +) + +const workflowTemplateLabel = "workflows.argoproj.io/workflow-template" + +type listOptions struct { + Templates []string + Kind string + DeviceType string + Target string + SubmittedBy string + Status string + Selector string + Output string +} + +func newCmdList() *cobra.Command { + opts := &listOptions{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List existing Argo workflows", + Long: "List workflows in the " + enrollWorkflowNamespace + " namespace, filtered by the labels " + + "UnderStack stamps on them. With no filters, all workflows are listed.", + Example: ` # Failed enrollments + understackctl workflow list --kind enroll --status Failed,Error + + # Only server and firewall enrollments + understackctl workflow list --template enroll-server,enroll-fw + + # Everything that touched a node + understackctl workflow list --target 10.46.6.165`, + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + argoArgs := buildListArgoArgs(opts) + + currentContext, err := currentKubeContext() + if err != nil { + return fmt.Errorf("get current Kubernetes context: %w", err) + } + + cmd.Printf("Kubernetes context: %s\n", currentContext) + cmd.Printf("Running: %s\n", shellQuoteCommand("argo", argoArgs)) + + argoCmd := exec.Command("argo", argoArgs...) + argoCmd.Stdin = os.Stdin + argoCmd.Stderr = cmd.ErrOrStderr() + + // An explicit -o is passed straight through to argo. Otherwise we + // fetch JSON and render the default table ourselves, since argo's + // has no way to drop its PRIORITY column. + if opts.Output != "" { + argoCmd.Stdout = cmd.OutOrStdout() + if err := argoCmd.Run(); err != nil { + return fmt.Errorf("list argo workflows: %w", err) + } + + return nil + } + + var stdout bytes.Buffer + argoCmd.Stdout = &stdout + if err := argoCmd.Run(); err != nil { + return fmt.Errorf("list argo workflows: %w", err) + } + + return renderWorkflowTable(cmd.OutOrStdout(), stdout.Bytes(), time.Now()) + }, + } + + cmd.Flags().StringSliceVarP(&opts.Templates, "template", "t", nil, "Only workflows from these WorkflowTemplates, comma separated or repeated (e.g. enroll-server,enroll-fw)") + cmd.Flags().StringVar(&opts.Kind, "kind", "", "Only workflows of this kind (enroll, inspect)") + cmd.Flags().StringVar(&opts.DeviceType, "device-type", "", "Only workflows for this device type (server, netdev, fw)") + cmd.Flags().StringVar(&opts.Target, "target", "", "Only workflows submitted for this IP address, node name or node ID") + cmd.Flags().StringVar(&opts.SubmittedBy, "submitted-by", "", "Only workflows submitted by this user") + cmd.Flags().StringVar(&opts.Status, "status", "", "Only workflows in these phases, comma separated (e.g. Failed,Error)") + cmd.Flags().StringVarP(&opts.Selector, "selector", "l", "", "Additional label selector, ANDed with the filters above") + cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output format passed to argo (wide, name, json, yaml)") + + return cmd +} + +// buildListSelector joins the label filters, and any user-supplied selector, +// into a single label selector. Values are sanitized the same way as when the +// labels are stamped at submission, so a target typed as given still matches. +func buildListSelector(opts *listOptions) string { + var terms []string + if templates := nonEmpty(opts.Templates); len(templates) == 1 { + terms = append(terms, fmt.Sprintf("%s=%s", workflowTemplateLabel, templates[0])) + } else if len(templates) > 1 { + terms = append(terms, fmt.Sprintf("%s in (%s)", workflowTemplateLabel, strings.Join(templates, ","))) + } + for _, f := range []struct{ key, value string }{ + {"workflow-kind", opts.Kind}, + {"device-type", opts.DeviceType}, + {"target", opts.Target}, + {"submitted-by", opts.SubmittedBy}, + } { + if v := sanitizeLabelValue(f.value); v != "" { + terms = append(terms, fmt.Sprintf("%s%s=%s", labelPrefix, f.key, v)) + } + } + if opts.Selector != "" { + terms = append(terms, opts.Selector) + } + + return strings.Join(terms, ",") +} + +func nonEmpty(values []string) []string { + var out []string + for _, v := range values { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + + return out +} + +func buildListArgoArgs(opts *listOptions) []string { + args := []string{"-n", enrollWorkflowNamespace, "list"} + + if selector := buildListSelector(opts); selector != "" { + args = append(args, "-l", selector) + } + if opts.Status != "" { + args = append(args, "--status", opts.Status) + } + if opts.Output != "" { + args = append(args, "-o", opts.Output) + } else { + args = append(args, "-o", "json") + } + + return args +} + +type workflowItem struct { + Metadata struct { + Name string `json:"name"` + CreationTimestamp time.Time `json:"creationTimestamp"` + } `json:"metadata"` + Status struct { + Phase string `json:"phase"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + } `json:"status"` +} + +// renderWorkflowTable prints the output of `argo list -o json` as a table of +// NAME, STATUS, AGE and DURATION, newest first. +func renderWorkflowTable(w io.Writer, data []byte, now time.Time) error { + var items []workflowItem + if err := json.Unmarshal(data, &items); err != nil { + return fmt.Errorf("parse argo list output: %w", err) + } + + if len(items) == 0 { + _, err := fmt.Fprintln(w, "No workflows found") + return err + } + + sort.SliceStable(items, func(i, j int) bool { + return items[i].Metadata.CreationTimestamp.After(items[j].Metadata.CreationTimestamp) + }) + + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + // tabwriter buffers, so write errors surface from Flush below. + _, _ = fmt.Fprintln(tw, "NAME\tSTATUS\tAGE\tDURATION") + for _, item := range items { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + item.Metadata.Name, + item.Status.Phase, + duration.HumanDuration(now.Sub(item.Metadata.CreationTimestamp)), + workflowDuration(item, now), + ) + } + + return tw.Flush() +} + +// workflowDuration is how long the workflow ran, or has been running. +func workflowDuration(item workflowItem, now time.Time) string { + if item.Status.StartedAt.IsZero() { + return "0s" + } + + end := item.Status.FinishedAt + if end.IsZero() { + end = now + } + + return duration.HumanDuration(end.Sub(item.Status.StartedAt)) +} diff --git a/cmd/workflow/list_test.go b/cmd/workflow/list_test.go new file mode 100644 index 0000000..b520dfb --- /dev/null +++ b/cmd/workflow/list_test.go @@ -0,0 +1,98 @@ +package workflow + +import ( + "bytes" + "reflect" + "strings" + "testing" + "time" +) + +func TestBuildListArgoArgsNoFilters(t *testing.T) { + got := buildListArgoArgs(&listOptions{}) + want := []string{"-n", enrollWorkflowNamespace, "list", "-o", "json"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v want %#v", got, want) + } +} + +func TestBuildListArgoArgsFilters(t *testing.T) { + got := buildListArgoArgs(&listOptions{ + Templates: []string{"enroll-server", "enroll-fw"}, + Kind: "enroll", + DeviceType: "fw", + Target: "fw a", + Status: "Failed,Error", + Selector: "team=net", + Output: "wide", + }) + want := []string{ + "-n", enrollWorkflowNamespace, "list", + "-l", "workflows.argoproj.io/workflow-template in (enroll-server,enroll-fw)," + + "understack.rackspace.com/workflow-kind=enroll," + + "understack.rackspace.com/device-type=fw," + + "understack.rackspace.com/target=fw-a," + + "team=net", + "--status", "Failed,Error", + "-o", "wide", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v want %#v", got, want) + } +} + +func TestRenderWorkflowTable(t *testing.T) { + now := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC) + data := []byte(`[ + {"metadata":{"name":"old","creationTimestamp":"2026-10-28T09:00:00Z"}, + "status":{"phase":"Succeeded","startedAt":"2026-10-28T09:00:00Z","finishedAt":"2026-10-28T09:05:00Z"}}, + {"metadata":{"name":"new","creationTimestamp":"2026-10-28T11:50:00Z"}, + "status":{"phase":"Running","startedAt":"2026-10-28T11:50:00Z"}}, + {"metadata":{"name":"pending","creationTimestamp":"2026-10-28T11:59:30Z"}, + "status":{"phase":"Pending"}} + ]`) + + var out bytes.Buffer + if err := renderWorkflowTable(&out, data, now); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 4 { + t.Fatalf("expected 4 lines, got %d:\n%s", len(lines), out.String()) + } + if strings.Contains(out.String(), "PRIORITY") { + t.Fatalf("PRIORITY column should not be shown:\n%s", out.String()) + } + + for i, want := range [][]string{ + {"NAME", "STATUS", "AGE", "DURATION"}, + {"pending", "Pending", "30s", "0s"}, + {"new", "Running", "10m", "10m"}, + {"old", "Succeeded", "3h", "5m"}, + } { + if got := strings.Fields(lines[i]); !reflect.DeepEqual(got, want) { + t.Errorf("line %d: got %v want %v", i, got, want) + } + } +} + +func TestRenderWorkflowTableEmpty(t *testing.T) { + for _, in := range []string{"[]", "null"} { + var out bytes.Buffer + if err := renderWorkflowTable(&out, []byte(in), time.Now()); err != nil { + t.Fatalf("%s: unexpected error: %v", in, err) + } + if strings.TrimSpace(out.String()) != "No workflows found" { + t.Errorf("%s: got %q", in, out.String()) + } + } +} + +func TestBuildListSelectorSingleTemplate(t *testing.T) { + got := buildListSelector(&listOptions{Templates: []string{" enroll-server ", ""}}) + want := "workflows.argoproj.io/workflow-template=enroll-server" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} diff --git a/cmd/workflow/submit.go b/cmd/workflow/submit.go new file mode 100644 index 0000000..9d44ca8 --- /dev/null +++ b/cmd/workflow/submit.go @@ -0,0 +1,147 @@ +package workflow + +import ( + "bytes" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "os/user" + "regexp" + "strings" + + "github.com/spf13/cobra" +) + +const labelPrefix = "understack.rackspace.com/" + +var invalidLabelChars = regexp.MustCompile(`[^A-Za-z0-9_.-]+`) + +// sanitizeLabelValue coerces s into a valid Kubernetes label value: at most 63 +// characters of alphanumerics, '-', '_' and '.', starting and ending with an +// alphanumeric. +func sanitizeLabelValue(s string) string { + s = invalidLabelChars.ReplaceAllString(s, "-") + if len(s) > 63 { + s = s[:63] + } + + return strings.Trim(s, "-_.") +} + +// submitLabels returns the comma-separated labels stamped on a submitted +// workflow. Empty values are omitted. The workflow-kind and device-type labels +// come from the WorkflowTemplate itself. +func submitLabels(target, submittedBy string) string { + var labels []string + if v := sanitizeLabelValue(target); v != "" { + labels = append(labels, labelPrefix+"target="+v) + } + if v := sanitizeLabelValue(submittedBy); v != "" { + labels = append(labels, labelPrefix+"submitted-by="+v) + } + + return strings.Join(labels, ",") +} + +// submitWorkflow runs `argo submit` for the given args, which must include +// `-o name` so that stdout is just the generated workflow name. It labels the +// workflow with its target, prints the name and, if logs is set, follows the +// workflow logs. +func submitWorkflow(cmd *cobra.Command, argoArgs []string, target string, logs bool) error { + currentContext, err := currentKubeContext() + if err != nil { + return fmt.Errorf("get current Kubernetes context: %w", err) + } + + cmd.Printf("Kubernetes context: %s\n", currentContext) + var submittedBy string + if u, err := user.Current(); err == nil { + submittedBy = u.Username + } + if labels := submitLabels(target, submittedBy); labels != "" { + argoArgs = append(argoArgs, "--labels", labels) + } + + cmd.Printf("Running: %s\n", shellQuoteCommand("argo", argoArgs)) + + var stdout bytes.Buffer + argoCmd := exec.Command("argo", argoArgs...) + argoCmd.Stdin = os.Stdin + argoCmd.Stdout = &stdout + argoCmd.Stderr = cmd.ErrOrStderr() + + if err := argoCmd.Run(); err != nil { + return fmt.Errorf("submit argo workflow: %w", err) + } + + name := strings.TrimSpace(stdout.String()) + if name == "" { + return fmt.Errorf("argo submit did not return a workflow name") + } + + cmd.Printf("Workflow: %s\n", name) + + if server, err := currentKubeServer(); err == nil { + if uiURL, ok := workflowUIURL(server, enrollWorkflowNamespace, name); ok { + cmd.Printf("Argo UI: %s\n", uiURL) + } + } + + if !logs { + return nil + } + + logsArgs := []string{"-n", enrollWorkflowNamespace, "logs", "--follow", name} + logsCmd := exec.Command("argo", logsArgs...) + logsCmd.Stdin = os.Stdin + logsCmd.Stdout = cmd.OutOrStdout() + logsCmd.Stderr = cmd.ErrOrStderr() + + if err := logsCmd.Run(); err != nil { + return fmt.Errorf("follow logs for workflow %s: %w", name, err) + } + + return nil +} + +// buildSubmitArgs returns the argo arguments to submit a WorkflowTemplate, +// printing only the generated workflow name. +func buildSubmitArgs(template string, params ...string) []string { + args := []string{ + "-n", enrollWorkflowNamespace, + "submit", + "--from", template, + "-o", "name", + } + for _, p := range params { + args = append(args, "-p", p) + } + + return args +} + +// workflowUIURL derives the Argo Workflows UI link for a workflow from the +// Kubernetes API server URL by replacing its left-most hostname label with +// "workflows" (e.g. https://kubernetes.dc.example.com:6443 -> +// https://workflows.dc.example.com). It returns false when the host is an IP +// address or has no domain to swap into. +func workflowUIURL(server, namespace, name string) (string, bool) { + u, err := url.Parse(server) + if err != nil { + return "", false + } + + host := u.Hostname() + if host == "" || net.ParseIP(host) != nil { + return "", false + } + + _, domain, found := strings.Cut(host, ".") + if !found || domain == "" { + return "", false + } + + return fmt.Sprintf("https://workflows.%s/workflows/%s/%s", domain, namespace, name), true +} diff --git a/cmd/workflow/submit_test.go b/cmd/workflow/submit_test.go new file mode 100644 index 0000000..86368aa --- /dev/null +++ b/cmd/workflow/submit_test.go @@ -0,0 +1,56 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestWorkflowUIURL(t *testing.T) { + tests := []struct { + server string + want string + ok bool + }{ + {"https://kubernetes.dc.example.com:6443", "https://workflows.dc.example.com/workflows/argo-events/enroll-server-abc", true}, + {"https://k8s.example.com", "https://workflows.example.com/workflows/argo-events/enroll-server-abc", true}, + {"https://10.0.0.1:6443", "", false}, + {"https://[fd00::1]:6443", "", false}, + {"https://localhost:6443", "", false}, + {"", "", false}, + } + for _, tc := range tests { + got, ok := workflowUIURL(tc.server, "argo-events", "enroll-server-abc") + if got != tc.want || ok != tc.ok { + t.Errorf("workflowUIURL(%q) = %q, %v; want %q, %v", tc.server, got, ok, tc.want, tc.ok) + } + } +} + +func TestSanitizeLabelValue(t *testing.T) { + tests := map[string]string{ + "10.46.6.165": "10.46.6.165", + "fw-a.dc.example.com": "fw-a.dc.example.com", + "Doug Goldstein": "Doug-Goldstein", + "corp\\doug": "corp-doug", + "-weird_": "weird", + "": "", + strings.Repeat("a", 70): strings.Repeat("a", 63), + strings.Repeat("a", 62) + "-b": strings.Repeat("a", 62), + } + for in, want := range tests { + if got := sanitizeLabelValue(in); got != want { + t.Errorf("sanitizeLabelValue(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSubmitLabels(t *testing.T) { + got := submitLabels("10.46.6.165", "doug") + want := "understack.rackspace.com/target=10.46.6.165,understack.rackspace.com/submitted-by=doug" + if got != want { + t.Errorf("got %q, want %q", got, want) + } + if got := submitLabels("", ""); got != "" { + t.Errorf("expected no labels, got %q", got) + } +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go new file mode 100644 index 0000000..8ee5db7 --- /dev/null +++ b/cmd/workflow/workflow.go @@ -0,0 +1,19 @@ +package workflow + +import "github.com/spf13/cobra" + +func NewCmdWorkflow() *cobra.Command { + cmd := &cobra.Command{ + Use: "workflow", + Short: "Submit and manage UnderStack Argo workflows", + Long: "Submit and manage node lifecycle workflows by wrapping Argo workflows and related platform APIs", + } + + cmd.AddCommand(newCmdEnrollServer()) + cmd.AddCommand(newCmdEnrollNetdev()) + cmd.AddCommand(newCmdEnrollFW()) + cmd.AddCommand(newCmdInspectServer()) + cmd.AddCommand(newCmdList()) + + return cmd +}