From 6aa21777374b2ac0bfc38ef4486eb47cff7f1795 Mon Sep 17 00:00:00 2001 From: gene-redpanda <123959009+gene-redpanda@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:44:12 -0500 Subject: [PATCH] feat: add --format to list List is working as intended. Everything is driven by the data and it flows through the various formats with formatting separate from the assembly of the data. --- src/go/rpk/pkg/cli/topic/list.go | 144 ++++++++++++++++++- src/go/rpk/pkg/cli/topic/list_test.go | 195 ++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 src/go/rpk/pkg/cli/topic/list_test.go diff --git a/src/go/rpk/pkg/cli/topic/list.go b/src/go/rpk/pkg/cli/topic/list.go index fbd36488da5b..8fd05a219d15 100644 --- a/src/go/rpk/pkg/cli/topic/list.go +++ b/src/go/rpk/pkg/cli/topic/list.go @@ -11,13 +11,16 @@ package topic import ( "context" + "fmt" + "io" + "os" - "github.com/redpanda-data/redpanda/src/go/rpk/pkg/cli/cluster" "github.com/redpanda-data/redpanda/src/go/rpk/pkg/config" "github.com/redpanda-data/redpanda/src/go/rpk/pkg/kafka" "github.com/redpanda-data/redpanda/src/go/rpk/pkg/out" "github.com/spf13/afero" "github.com/spf13/cobra" + "github.com/twmb/franz-go/pkg/kadm" ) func newListCommand(fs afero.Fs, p *config.Params) *cobra.Command { @@ -55,6 +58,17 @@ information. // We forbid deleting internal topics (redpanda // actually does not expose these currently), so we // make -r and -i incompatible. + + f := p.Formatter + if detailed { + if h, ok := f.Help([]detailedListTopic{}); ok { + out.Exit(h) + } + } else { + if h, ok := f.Help([]summarizedList{}); ok { + out.Exit(h) + } + } if internal && re { out.Exit("cannot list with internal topics and list by regular expression") } @@ -73,12 +87,138 @@ information. listed, err := adm.ListTopicsWithInternal(context.Background(), topics...) out.MaybeDie(err, "unable to request metadata: %v", err) - cluster.PrintTopics(listed, internal, detailed) + + if detailed { + printDetailedListView(f, detailedListView(internal, listed), os.Stdout) + } else { + printSummarizedListView(f, summarizedListView(internal, listed), os.Stdout) + } + out.MaybeDie(err, "unable to summarize metadata: %v", err) }, } + p.InstallFormatFlag(cmd) cmd.Flags().BoolVarP(&detailed, "detailed", "d", false, "Print per-partition information for topics") cmd.Flags().BoolVarP(&internal, "internal", "i", false, "Print internal topics") cmd.Flags().BoolVarP(&re, "regex", "r", false, "Parse topics as regex; list any topic that matches any input topic expression") return cmd } + +type summarizedList struct { + Name string `json:"name" yaml:"name"` + Partitions int `json:"partitions" yaml:"partitions"` + Replicas int `json:"replicas" yaml:"replicas"` +} + +func summarizedListView(internal bool, topics kadm.TopicDetails) (resp []summarizedList) { + resp = make([]summarizedList, 0, len(topics)) + for _, topic := range topics.Sorted() { + if !internal && topic.IsInternal { + continue + } + s := summarizedList{ + Name: topic.Topic, + Partitions: len(topic.Partitions), + Replicas: topic.Partitions.NumReplicas(), + } + resp = append(resp, s) + } + return +} + +func printSummarizedListView(f config.OutFormatter, topics []summarizedList, w io.Writer) { + if isText, _, t, err := f.Format(topics); !isText { + out.MaybeDie(err, "unable to print in the requested format %q: %v", f.Kind, err) + fmt.Fprintf(w, "%s\n", t) + return + } + + tw := out.NewTableTo(w, "NAME", "PARTITIONS", "REPLICAS") + defer tw.Flush() + for _, topic := range topics { + tw.Print(topic.Name, topic.Partitions, topic.Replicas) + } +} + +type detailedListTopic struct { + Name string `json:"name" yaml:"name"` + Partitions int `json:"partitions" yaml:"partitions"` + Replicas int `json:"replicas" yaml:"replicas"` + PartitionList []detailedListPartition `json:"partition_list" yaml:"partition_list"` + hasOfflineReplicas bool + isInternal bool +} + +type detailedListPartition struct { + Partition int32 `json:"partition" yaml:"partition"` + Leader int32 `json:"leader" yaml:"leader"` + Epoch int32 `json:"epoch" yaml:"epoch"` + Replicas []int32 `json:"replicas" yaml:"replicas"` + OfflineReplicas []int32 `json:"offline_replicas,omitempty" yaml:"offline_replicas,omitempty"` +} + +func detailedListView(internal bool, topics kadm.TopicDetails) (resp []detailedListTopic) { + resp = make([]detailedListTopic, 0, len(topics)) + for _, topic := range topics.Sorted() { + if !internal && topic.IsInternal { + continue + } + d := detailedListTopic{ + Name: topic.Topic, + Partitions: len(topic.Partitions), + isInternal: topic.IsInternal, + } + if len(topic.Partitions) > 0 { + d.Replicas = len(topic.Partitions[0].Replicas) + d.PartitionList = make([]detailedListPartition, len(topic.Partitions)) + } + + for k, p := range topic.Partitions.Sorted() { + d.PartitionList[k].Partition = p.Partition + d.PartitionList[k].Replicas = int32s(p.Replicas).sort() + d.PartitionList[k].Leader = p.Leader + if p.LeaderEpoch != -1 { + d.PartitionList[k].Epoch = p.LeaderEpoch + } + if len(p.OfflineReplicas) > 0 { + d.hasOfflineReplicas = true + d.PartitionList[k].OfflineReplicas = int32s(p.OfflineReplicas).sort() + } + } + resp = append(resp, d) + } + return +} + +func printDetailedListView(f config.OutFormatter, topics []detailedListTopic, w io.Writer) { + if isText, _, t, err := f.Format(topics); !isText { + out.MaybeDie(err, "unable to print in the requested format %q: %v", f.Kind, err) + fmt.Fprintf(w, "%s\n", t) + return + } + + for _, topic := range topics { + var topicName string + if topic.isInternal { + topicName = fmt.Sprintf("%s (internal),", topic.Name) + } else { + topicName = fmt.Sprintf("%s,", topic.Name) + } + fmt.Fprintf(w, "%s %d partitions, %d replicas\n", topicName, topic.Partitions, topic.Replicas) + var tw *out.TabWriter + if topic.hasOfflineReplicas { + tw = out.NewTableTo(w, "", "PARTITION", "LEADER", "EPOCH", "REPLICAS", "OFFLINE_REPLICAS") + } else { + tw = out.NewTableTo(w, "", "PARTITION", "LEADER", "EPOCH", "REPLICAS") + } + for _, p := range topic.PartitionList { + if topic.hasOfflineReplicas { + tw.Print("", p.Partition, p.Leader, p.Epoch, p.Replicas, p.OfflineReplicas) + } else { + tw.Print("", p.Partition, p.Leader, p.Epoch, p.Replicas) + } + } + tw.Flush() + fmt.Fprintf(w, "\n") + } +} diff --git a/src/go/rpk/pkg/cli/topic/list_test.go b/src/go/rpk/pkg/cli/topic/list_test.go new file mode 100644 index 000000000000..ae4aea06cbae --- /dev/null +++ b/src/go/rpk/pkg/cli/topic/list_test.go @@ -0,0 +1,195 @@ +package topic + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/redpanda-data/redpanda/src/go/rpk/pkg/config" + "github.com/stretchr/testify/require" + "github.com/twmb/franz-go/pkg/kadm" + "gopkg.in/yaml.v3" +) + +func setupTestTopics() kadm.TopicDetails { + return kadm.TopicDetails{ + "test-topic": { + Topic: "test-topic", + IsInternal: false, + Partitions: kadm.PartitionDetails{ + 0: { + Partition: 0, + Leader: 1, + LeaderEpoch: 5, + Replicas: []int32{1, 2, 3}, + ISR: []int32{1, 2, 3}, + }, + 1: { + Partition: 1, + Leader: 2, + LeaderEpoch: 3, + Replicas: []int32{1, 2, 3}, + ISR: []int32{2, 3}, + OfflineReplicas: []int32{1}, + }, + }, + }, + "internal-topic": { + Topic: "internal-topic", + IsInternal: true, + Partitions: kadm.PartitionDetails{ + 0: { + Partition: 0, + Leader: 1, + LeaderEpoch: 1, + Replicas: []int32{1}, + ISR: []int32{1}, + }, + }, + }, + } +} + +type testCase struct { + Kind string + Output string +} + +func JSON(t *testing.T, o any) testCase { + expected, err := json.Marshal(o) + require.NoError(t, err) + return testCase{Kind: "json", Output: string(expected) + "\n"} +} + +func YAML(t *testing.T, o any) testCase { + expected, err := yaml.Marshal(o) + require.NoError(t, err) + return testCase{Kind: "yaml", Output: string(expected) + "\n"} +} + +func Text(s string) testCase { + return testCase{Kind: "text", Output: s} +} + +func TestSummarizedListView(t *testing.T) { + topics := setupTestTopics() + s := summarizedListView(false, topics) + + cases := []testCase{ + Text(`NAME PARTITIONS REPLICAS +test-topic 2 3 +`), + JSON(t, s), + YAML(t, s), + } + + for _, c := range cases { + f := config.OutFormatter{Kind: c.Kind} + b := &strings.Builder{} + printSummarizedListView(f, s, b) + require.Equal(t, c.Output, b.String()) + } +} + +func TestDetailedListView(t *testing.T) { + topics := setupTestTopics() + d := detailedListView(false, topics) + + cases := []testCase{ + Text(`test-topic, 2 partitions, 3 replicas + PARTITION LEADER EPOCH REPLICAS OFFLINE_REPLICAS + 0 1 5 [1 2 3] [] + 1 2 3 [1 2 3] [1] + +`), + JSON(t, d), + YAML(t, d), + } + + for _, c := range cases { + f := config.OutFormatter{Kind: c.Kind} + b := &strings.Builder{} + printDetailedListView(f, d, b) + require.Equal(t, c.Output, b.String()) + } +} + +func TestSummarizedListViewWithInternal(t *testing.T) { + topics := setupTestTopics() + s := summarizedListView(true, topics) + + cases := []testCase{ + Text(`NAME PARTITIONS REPLICAS +internal-topic 1 1 +test-topic 2 3 +`), + JSON(t, s), + YAML(t, s), + } + + for _, c := range cases { + f := config.OutFormatter{Kind: c.Kind} + b := &strings.Builder{} + printSummarizedListView(f, s, b) + require.Equal(t, c.Output, b.String()) + } +} + +func TestDetailedListViewWithInternal(t *testing.T) { + topics := setupTestTopics() + d := detailedListView(true, topics) + + cases := []testCase{ + Text(`internal-topic (internal), 1 partitions, 1 replicas + PARTITION LEADER EPOCH REPLICAS + 0 1 1 [1] + +test-topic, 2 partitions, 3 replicas + PARTITION LEADER EPOCH REPLICAS OFFLINE_REPLICAS + 0 1 5 [1 2 3] [] + 1 2 3 [1 2 3] [1] + +`), + JSON(t, d), + YAML(t, d), + } + + for _, c := range cases { + f := config.OutFormatter{Kind: c.Kind} + b := &strings.Builder{} + printDetailedListView(f, d, b) + require.Equal(t, c.Output, b.String()) + } +} + +func TestEmptyTopicList(t *testing.T) { + emptyTopics := kadm.TopicDetails{} + s := summarizedListView(false, emptyTopics) + d := detailedListView(false, emptyTopics) + + for _, format := range []string{"text", "json", "yaml"} { + f := config.OutFormatter{Kind: format} + b := &strings.Builder{} + + printSummarizedListView(f, s, b) + switch format { + case "text": + require.Equal(t, "NAME PARTITIONS REPLICAS\n", b.String()) + case "json": + require.Equal(t, "[]\n", b.String()) + case "yaml": + require.Equal(t, "[]\n\n", b.String()) + } + + b.Reset() + printDetailedListView(f, d, b) + switch format { + case "text": + require.Empty(t, b.String()) + case "json": + require.Equal(t, "[]\n", b.String()) + case "yaml": + require.Equal(t, "[]\n\n", b.String()) + } + } +}