From 3d286fe02b2c27aab7a27cc7023b589feb76a487 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 27 Oct 2026 08:48:10 +0000 Subject: [PATCH] rpk-k8s: report command flags in --help-autocomplete rpk builds the reference docs for plugin commands from the --help-autocomplete output, which carried no flag metadata. Each entry now has a flags array in the shape rpk prints for its own flags in --print-tree: local, non-hidden flags with typed defaults. args now lists the command's flag names ("--name") for completion, as rpk expects. It used to carry ValidArgs, which no rpk-k8s command sets, so it was always null. Cobra's help and completion commands are skipped if they are ever present in the walk. --- .../operator-Added-20261027-100000.yaml | 7 ++ operator/cmd/rpk-k8s/main.go | 112 ++++++++++++++++-- operator/cmd/rpk-k8s/main_test.go | 94 +++++++++++++++ 3 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 .changes/unreleased/operator-Added-20261027-100000.yaml create mode 100644 operator/cmd/rpk-k8s/main_test.go diff --git a/.changes/unreleased/operator-Added-20261027-100000.yaml b/.changes/unreleased/operator-Added-20261027-100000.yaml new file mode 100644 index 0000000000..d6f924567e --- /dev/null +++ b/.changes/unreleased/operator-Added-20261027-100000.yaml @@ -0,0 +1,7 @@ +project: operator +kind: Added +body: |- + `rpk k8s` reports each command's flags to rpk, so shell completion for `rpk k8s` commands lists their flags and `rpk --print-tree` includes them + + Previously the plugin reported no flags for its commands. +time: 2026-10-27T10:00:00.000000+00:00 diff --git a/operator/cmd/rpk-k8s/main.go b/operator/cmd/rpk-k8s/main.go index 6bc24276ce..7593819e69 100644 --- a/operator/cmd/rpk-k8s/main.go +++ b/operator/cmd/rpk-k8s/main.go @@ -13,8 +13,12 @@ import ( "encoding/json" "fmt" "os" + "slices" + "strconv" + "strings" "github.com/spf13/cobra" + "github.com/spf13/pflag" rpkk8s "github.com/redpanda-data/redpanda-operator/operator/cmd/rpk-k8s/k8s" ) @@ -54,11 +58,24 @@ func main() { } type pluginHelp struct { - Path string `json:"path"` - Short string `json:"short"` - Long string `json:"long"` - Example string `json:"example"` - Args []string `json:"args"` + Path string `json:"path"` + Short string `json:"short"` + Long string `json:"long"` + Example string `json:"example"` + Args []string `json:"args"` + Flags []pluginFlag `json:"flags"` +} + +// pluginFlag matches the shape rpk prints for its own flags in +// --print-tree, so rpk can document plugin flags the same way. +type pluginFlag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Type string `json:"type"` + Description string `json:"description"` + Default any `json:"default"` + Required bool `json:"required"` + Deprecated string `json:"deprecated,omitempty"` } func collectHelps(prefix string, cmd *cobra.Command) []pluginHelp { @@ -69,11 +86,14 @@ func collectHelps(prefix string, cmd *cobra.Command) []pluginHelp { Short: cmd.Short, Long: cmd.Long, Example: cmd.Example, - Args: cmd.ValidArgs, + Args: pluginHelpArgs(cmd), + Flags: pluginHelpFlags(cmd), }) for _, sub := range cmd.Commands() { - if sub.Hidden { + // Cobra only adds its help and completion commands at Execute + // time, but skip them in case that ever changes. + if sub.Hidden || sub.Name() == "help" || sub.Name() == "completion" { continue } childPrefix := prefix + "_" + sub.Name() @@ -82,3 +102,81 @@ func collectHelps(prefix string, cmd *cobra.Command) []pluginHelp { return helps } + +// pluginHelpArgs returns the flag names rpk offers for completion: every +// non-hidden local and inherited flag, as "--name". +func pluginHelpArgs(cmd *cobra.Command) []string { + seen := map[string]struct{}{} + var out []string + add := func(f *pflag.Flag) { + if f.Hidden || f.Name == "help" { + return + } + name := "--" + f.Name + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + out = append(out, name) + } + cmd.LocalFlags().VisitAll(add) + cmd.InheritedFlags().VisitAll(add) + return out +} + +// pluginHelpFlags returns the flags defined on cmd itself, skipping hidden +// flags and help. Inherited flags are reported on the command that defines +// them. +func pluginHelpFlags(cmd *cobra.Command) []pluginFlag { + // rpk expects an array, never null. + flags := []pluginFlag{} + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden || f.Name == "help" { + return + } + flags = append(flags, pluginFlag{ + Name: f.Name, + Shorthand: f.Shorthand, + Type: f.Value.Type(), + Description: f.Usage, + Default: parseDefault(f), + Required: slices.Contains(f.Annotations[cobra.BashCompOneRequiredFlag], "true"), + Deprecated: f.Deprecated, + }) + }) + return flags +} + +// parseDefault is copied from rpk's printtree.go so plugin defaults are +// typed the same way as rpk's own. +func parseDefault(f *pflag.Flag) any { + t := f.Value.Type() + if strings.HasSuffix(t, "Slice") || strings.HasSuffix(t, "Array") { + if f.DefValue == "" || f.DefValue == "[]" { + return []any{} + } + return f.DefValue + } + if f.DefValue == "" { + if t == "string" { + return "" + } + return nil + } + switch t { + case "bool": + if b, err := strconv.ParseBool(f.DefValue); err == nil { + return b + } + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64", "count": + if n, err := strconv.ParseInt(f.DefValue, 10, 64); err == nil { + return n + } + case "float32", "float64": + if x, err := strconv.ParseFloat(f.DefValue, 64); err == nil { + return x + } + } + return f.DefValue +} diff --git a/operator/cmd/rpk-k8s/main_test.go b/operator/cmd/rpk-k8s/main_test.go new file mode 100644 index 0000000000..c9bcac961c --- /dev/null +++ b/operator/cmd/rpk-k8s/main_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestCollectHelpsFlags(t *testing.T) { + root := &cobra.Command{Use: "k8s", Short: "root"} + root.PersistentFlags().String("kubeconfig", "", "path to kubeconfig") + + sub := &cobra.Command{ + Use: "status", + Short: "status short", + ValidArgs: []string{"positional"}, + Run: func(*cobra.Command, []string) {}, + } + sub.Flags().BoolP("verbose", "v", false, "verbose output") + sub.Flags().Int("replicas", 3, "replica count") + sub.Flags().Duration("timeout", 30*time.Second, "timeout") + sub.Flags().StringSlice("names", nil, "names") + sub.Flags().String("namespace", "default", "namespace") + sub.Flags().String("secret", "", "hidden flag") + require.NoError(t, sub.Flags().MarkHidden("secret")) + sub.Flags().String("must", "", "required flag") + require.NoError(t, sub.MarkFlagRequired("must")) + // MarkDeprecated also hides the flag, so it is skipped like rpk's own. + sub.Flags().String("gone", "", "deprecated flag") + require.NoError(t, sub.Flags().MarkDeprecated("gone", "use --must")) + // Set Deprecated directly to cover the field on a visible flag. + sub.Flags().String("old", "", "old flag") + sub.Flags().Lookup("old").Deprecated = "use --must" + + bare := &cobra.Command{Use: "bare", Run: func(*cobra.Command, []string) {}} + hidden := &cobra.Command{Use: "secret", Hidden: true, Run: func(*cobra.Command, []string) {}} + root.AddCommand(sub, bare, hidden) + // Cobra normally adds these at Execute time; add them so the walk + // proves it skips them. + root.InitDefaultHelpCmd() + root.InitDefaultCompletionCmd() + root.InitDefaultHelpFlag() + sub.InitDefaultHelpFlag() + + helps := collectHelps("k8s", root) + + // Round-trip through JSON so defaults are checked as rpk sees them. + out, err := json.Marshal(helps) + require.NoError(t, err) + var got []map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + + var paths []any + for _, h := range got { + paths = append(paths, h["path"]) + } + require.Equal(t, []any{"k8s", "k8s_bare", "k8s_status"}, paths, "help, completion and hidden commands must be skipped") + require.Equal(t, []any{}, got[1]["flags"], "flags must be [] rather than null") + require.Equal(t, []any{"--kubeconfig"}, got[1]["args"]) + + // The persistent flag appears once, on the command that defines it. + require.Equal(t, []any{ + map[string]any{"name": "kubeconfig", "type": "string", "description": "path to kubeconfig", "default": "", "required": false}, + }, got[0]["flags"]) + require.Equal(t, []any{"--kubeconfig"}, got[0]["args"]) + + require.Equal(t, []any{ + map[string]any{"name": "must", "type": "string", "description": "required flag", "default": "", "required": true}, + map[string]any{"name": "names", "type": "stringSlice", "description": "names", "default": []any{}, "required": false}, + map[string]any{"name": "namespace", "type": "string", "description": "namespace", "default": "default", "required": false}, + map[string]any{"name": "old", "type": "string", "description": "old flag", "default": "", "required": false, "deprecated": "use --must"}, + map[string]any{"name": "replicas", "type": "int", "description": "replica count", "default": float64(3), "required": false}, + map[string]any{"name": "timeout", "type": "duration", "description": "timeout", "default": "30s", "required": false}, + map[string]any{"name": "verbose", "shorthand": "v", "type": "bool", "description": "verbose output", "default": false, "required": false}, + }, got[2]["flags"]) + + // args lists flag names for completion, local then inherited, not + // ValidArgs. + require.Equal(t, []any{ + "--must", "--names", "--namespace", "--old", "--replicas", "--timeout", "--verbose", "--kubeconfig", + }, got[2]["args"]) +}