Skip to content

Commit

Permalink
rpk: group describe supporting regex
Browse files Browse the repository at this point in the history
  • Loading branch information
daisukebe committed Jun 15, 2024
1 parent d4b5ad4 commit 071cb3f
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 87 deletions.
38 changes: 37 additions & 1 deletion src/go/rpk/pkg/cli/group/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,37 @@ import (
"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/redpanda-data/redpanda/src/go/rpk/pkg/utils"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/twmb/franz-go/pkg/kadm"
)

func NewDescribeCommand(fs afero.Fs, p *config.Params) *cobra.Command {
var summary, commitsOnly, lagPerTopic bool
var summary, commitsOnly, lagPerTopic, re bool
cmd := &cobra.Command{
Use: "describe [GROUPS...]",
Short: "Describe group offset status & lag",
Long: `Describe group offset status & lag.
This command describes group members, calculates their lag, and prints detailed
information about the members.
The --regex flag (-r) parses arguments as regular expressions
and describes groups that match any of the expressions.
`,
Example: `
Describe groups foo and bar:
rpk group describe foo bar
Describe any group starting with f and ending in r:
rpk group describe -r '^f.*' '.*r$'
Describe all groups:
rpk group describe -r '*'
Describe any one-character group:
rpk group describe -r .
`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, groups []string) {
Expand All @@ -41,6 +58,14 @@ information about the members.
out.MaybeDie(err, "unable to initialize kafka client: %v", err)
defer adm.Close()

if re {
groups, err = regexGroups(adm, groups)
out.MaybeDie(err, "unable to filter groups by regex: %v", err)
}
if len(groups) == 0 {
out.Exit("did not match any groups, exiting.")
}

ctx, cancel := context.WithTimeout(cmd.Context(), p.Defaults().GetCommandTimeout())
defer cancel()

Expand All @@ -62,6 +87,7 @@ information about the members.
cmd.Flags().BoolVarP(&lagPerTopic, "print-lag-per-topic", "t", false, "Print the aggregated lag per topic")
cmd.Flags().BoolVarP(&summary, "print-summary", "s", false, "Print only the group summary section")
cmd.Flags().BoolVarP(&commitsOnly, "print-commits", "c", false, "Print only the group commits section")
cmd.Flags().BoolVarP(&re, "regex", "r", false, "Parse arguments as regex; describe any group that matches any input group expression")
cmd.MarkFlagsMutuallyExclusive("print-summary", "print-commits")
cmd.MarkFlagsMutuallyExclusive("print-lag-per-topic", "print-commits")
return cmd
Expand Down Expand Up @@ -238,3 +264,13 @@ func printLagPerTopic(groups kadm.DescribedGroupLags) {
}
}
}

func regexGroups(adm *kadm.Client, expressions []string) ([]string, error) {
// Now we list all groups to match against our expressions.
groups, err := adm.ListGroups(context.Background())
if err != nil {
return nil, fmt.Errorf("unable to list groups: %w", err)
}

return utils.RegexListedItems(groups.Groups(), expressions)
}
38 changes: 2 additions & 36 deletions src/go/rpk/pkg/cli/topic/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ package topic
import (
"context"
"fmt"
"regexp"
"strings"

"github.com/redpanda-data/redpanda/src/go/rpk/pkg/utils"
"github.com/twmb/franz-go/pkg/kadm"
)

Expand Down Expand Up @@ -57,39 +57,5 @@ func regexTopics(adm *kadm.Client, expressions []string) ([]string, error) {
return nil, fmt.Errorf("unable to list topics: %w", err)
}

return regexListedTopics(topics.Names(), expressions)
}

func regexListedTopics(topics, expressions []string) ([]string, error) {
var compiled []*regexp.Regexp
for _, expression := range expressions {
if !strings.HasPrefix(expression, "^") {
expression = "^" + expression
}
if !strings.HasSuffix(expression, "$") {
expression += "$"
}
re, err := regexp.Compile(expression)
if err != nil {
return nil, fmt.Errorf("unable to compile regex %q: %w", expression, err)
}
compiled = append(compiled, re)
}

var matched []string
for _, re := range compiled {
remaining := topics[:0]
for _, t := range topics {
if re.MatchString(t) {
matched = append(matched, t)
} else {
remaining = append(remaining, t)
}
}
topics = remaining
if len(topics) == 0 {
break
}
}
return matched, nil
return utils.RegexListedItems(topics.Names(), expressions)
}
50 changes: 0 additions & 50 deletions src/go/rpk/pkg/cli/topic/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,53 +81,3 @@ func TestParseKVs(t *testing.T) {
})
}
}

func TestRegexListedTopics(t *testing.T) {
for _, test := range []struct {
topics []string
exprs []string
exp []string
expErr bool
}{
{}, // no topics, no expressions: no error

{ // topic, no expressions: no change
topics: []string{"foo", "bar"},
},

{
topics: []string{"foo", "bar", "biz", "baz", "buzz"},
exprs: []string{".a.", "^f.."},
exp: []string{"bar", "baz", "foo"},
},

{ // dot matches nothing by default, because we anchor with ^ and $
topics: []string{"foo", "bar", "biz", "baz", "buzz"},
exprs: []string{"."},
},

{ // .* matches everything
topics: []string{"foo", "bar", "biz", "baz", "buzz"},
exprs: []string{".*"},
exp: []string{"foo", "bar", "biz", "baz", "buzz"},
},

{
exprs: []string{"as[df"},
expErr: true,
},

//
} {
got, err := regexListedTopics(test.topics, test.exprs)

gotErr := err != nil
if gotErr != test.expErr {
t.Errorf("got err? %v, exp? %v", gotErr, test.expErr)
}
if test.expErr {
return
}
require.Equal(t, test.exp, got, "got topics != expected")
}
}
51 changes: 51 additions & 0 deletions src/go/rpk/pkg/utils/regex_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright 2024 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 utils

import (
"fmt"
"regexp"
"strings"
)

// RegexListedItems returns items that match the 'expressions' from the 'list'.
func RegexListedItems(list, expressions []string) ([]string, error) {
var compiled []*regexp.Regexp
for _, expression := range expressions {
if !strings.HasPrefix(expression, "^") {
expression = "^" + expression
}
if !strings.HasSuffix(expression, "$") {
expression += "$"
}
re, err := regexp.Compile(expression)
if err != nil {
return nil, fmt.Errorf("unable to compile regex %q: %w", expression, err)
}
compiled = append(compiled, re)
}

var matched []string
for _, re := range compiled {
remaining := list[:0]
for _, item := range list {
if re.MatchString(item) {
matched = append(matched, item)
} else {
remaining = append(remaining, item)
}
}
list = remaining
if len(list) == 0 {
break
}
}
return matched, nil
}
88 changes: 88 additions & 0 deletions src/go/rpk/pkg/utils/regex_filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright 2024 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 utils

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestRegexListedItems(t *testing.T) {
tests := []struct {
name string
list []string
exprs []string
want []string
wantErr bool
}{
{
name: "no list, no expressions: no error",
},

{
name: "list, no expressions: no change",
list: []string{"foo", "bar"},
},

{
name: "matching ^f",
list: []string{"foo", "bar", "fdsa"},
exprs: []string{"^f.*"},
want: []string{"foo", "fdsa"},
},

{
name: "matching three",
list: []string{"foo", "bar", "biz", "baz", "buzz"},
exprs: []string{".a.", "^f.."},
want: []string{"bar", "baz", "foo"},
},

{
name: "dot matches nothing by default, because we anchor with ^ and $",
list: []string{"foo", "bar", "biz", "baz", "buzz"},
exprs: []string{"."},
},

{
name: ".* matches everything",
list: []string{"foo", "bar", "biz", "baz", "buzz"},
exprs: []string{".*"},
want: []string{"foo", "bar", "biz", "baz", "buzz"},
},

{
name: "* matches everything",
list: []string{"foo", "bar", "biz", "baz", "buzz"},
exprs: []string{".*"},
want: []string{"foo", "bar", "biz", "baz", "buzz"},
},

{
name: "no list",
exprs: []string{"as[df"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := RegexListedItems(tt.list, tt.exprs)
gotErr := err != nil
if gotErr != tt.wantErr {
t.Errorf("got err? %v, exp? %v", gotErr, tt.wantErr)
}
if tt.wantErr {
return
}
require.Equal(t, tt.want, got, "got topics != expected")
})
}
}

0 comments on commit 071cb3f

Please sign in to comment.