diff --git a/src/go/rpk/pkg/cli/group/describe.go b/src/go/rpk/pkg/cli/group/describe.go index 991d7c43f89c8..6ae057f53aa7c 100644 --- a/src/go/rpk/pkg/cli/group/describe.go +++ b/src/go/rpk/pkg/cli/group/describe.go @@ -17,13 +17,14 @@ 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", @@ -31,6 +32,22 @@ func NewDescribeCommand(fs afero.Fs, p *config.Params) *cobra.Command { 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) { @@ -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() @@ -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 @@ -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) +} diff --git a/src/go/rpk/pkg/cli/topic/utils.go b/src/go/rpk/pkg/cli/topic/utils.go index 005dc3adab44a..3704931dd4e54 100644 --- a/src/go/rpk/pkg/cli/topic/utils.go +++ b/src/go/rpk/pkg/cli/topic/utils.go @@ -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" ) @@ -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) } diff --git a/src/go/rpk/pkg/cli/topic/utils_test.go b/src/go/rpk/pkg/cli/topic/utils_test.go index 2f2fa7e1f809b..ee22d5b21ecbe 100644 --- a/src/go/rpk/pkg/cli/topic/utils_test.go +++ b/src/go/rpk/pkg/cli/topic/utils_test.go @@ -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") - } -} diff --git a/src/go/rpk/pkg/utils/regex_filter.go b/src/go/rpk/pkg/utils/regex_filter.go new file mode 100644 index 0000000000000..457e96bddedc6 --- /dev/null +++ b/src/go/rpk/pkg/utils/regex_filter.go @@ -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 +} diff --git a/src/go/rpk/pkg/utils/regex_filter_test.go b/src/go/rpk/pkg/utils/regex_filter_test.go new file mode 100644 index 0000000000000..e053f4c4b2d76 --- /dev/null +++ b/src/go/rpk/pkg/utils/regex_filter_test.go @@ -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") + }) + } +}