Skip to content

Commit

Permalink
feat: add --format to list
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
gene-redpanda committed Sep 21, 2024
1 parent 76ea911 commit 6aa2177
Show file tree
Hide file tree
Showing 2 changed files with 337 additions and 2 deletions.
144 changes: 142 additions & 2 deletions src/go/rpk/pkg/cli/topic/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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")
}
}
195 changes: 195 additions & 0 deletions src/go/rpk/pkg/cli/topic/list_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
}

0 comments on commit 6aa2177

Please sign in to comment.