Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

rpk: group describe supporting regex #19839

Merged
merged 1 commit into from
Jun 19, 2024
Merged
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
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")
})
}
}
Loading