Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changes/unreleased/operator-Added-20261027-100000.yaml
Original file line number Diff line number Diff line change
@@ -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
112 changes: 105 additions & 7 deletions operator/cmd/rpk-k8s/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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
}
94 changes: 94 additions & 0 deletions operator/cmd/rpk-k8s/main_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
Loading