diff --git a/internal/commands/examples.go b/internal/commands/examples.go index c8f1fb2a..94023d38 100644 --- a/internal/commands/examples.go +++ b/internal/commands/examples.go @@ -61,6 +61,7 @@ func showExamples() string { newExample("show dev -C postgres -C redis", "expand all but 2 components"), newExample("show dev -k deployment -k configmap", "show only deployments and config maps"), newExample("show dev -K secret", "show all objects except secrets"), + newExample("show dev -t 'Deployment/(redis.*)'", "show only deployments whose name starts with 'redis'"), newExample("show dev -O", "list all objects for the dev environment"), ) } diff --git a/internal/model/filter.go b/internal/model/filter.go index c50f618f..502b354c 100644 --- a/internal/model/filter.go +++ b/internal/model/filter.go @@ -16,6 +16,8 @@ package model import ( "fmt" + "regexp" + "slices" "strings" "k8s.io/gengo/namer" @@ -110,3 +112,30 @@ func newKindFilter(includes, excludes []string) (Filter, error) { } return bf, nil } + +type regexpFilter struct { + includes []*regexp.Regexp + excludes []*regexp.Regexp +} + +func (r *regexpFilter) HasFilters() bool { + return len(r.includes) > 0 || len(r.excludes) > 0 +} + +func (r *regexpFilter) ShouldInclude(s string) bool { + if len(r.includes) > 0 && !slices.ContainsFunc(r.includes, func(re *regexp.Regexp) bool { + return re.MatchString(s) + }) { + return false + } + return !slices.ContainsFunc(r.excludes, func(re *regexp.Regexp) bool { + return re.MatchString(s) + }) +} + +// newRegexpFilter returns a filter that matches values against the supplied compiled regular +// expressions. A value is included if it matches at least one include pattern and none of the +// exclude patterns. +func newRegexpFilter(includes, excludes []*regexp.Regexp) Filter { + return ®expFilter{includes: includes, excludes: excludes} +} diff --git a/internal/model/filter_test.go b/internal/model/filter_test.go index 747098e1..401f73d7 100644 --- a/internal/model/filter_test.go +++ b/internal/model/filter_test.go @@ -17,10 +17,37 @@ package model import ( "testing" + "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func newTestObject(kind, name string) K8sQbecMeta { + return NewK8sLocalObject(map[string]interface{}{ + "apiVersion": "v1", + "kind": kind, + "metadata": map[string]interface{}{"name": name}, + }, LocalAttrs{Component: "comp"}) +} + +// newTestFiltersErr parses the supplied flags through the filters setup and returns the filters +// along with any setup error. +func newTestFiltersErr(t *testing.T, args ...string) (Filters, error) { + t.Helper() + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + fn := NewFilters(flags, true) + require.Nil(t, flags.Parse(args)) + return fn() +} + +// newTestFilters parses the supplied flags through the filters setup and returns the filters. +func newTestFilters(t *testing.T, args ...string) Filters { + t.Helper() + f, err := newTestFiltersErr(t, args...) + require.Nil(t, err) + return f +} + func TestComponentFilterIncludes(t *testing.T) { filter, err := NewComponentFilter([]string{"foo", "bar"}, []string{}) require.Nil(t, err) @@ -118,3 +145,37 @@ func TestKindFilterBad(t *testing.T) { require.NotNil(t, err) require.Equal(t, "cannot include as well as exclude kinds, specify one or the other", err.Error()) } + +func TestObjectPatternFilterIncludes(t *testing.T) { + f := newTestFilters(t, + "--object-pattern=Deployment/(foo.*)", + "--object-pattern=ConfigMap/(bar.*)", + ) + a := assert.New(t) + a.True(f.ObjectFilter(newTestObject("Deployment", "foo-1"))) + a.True(f.ObjectFilter(newTestObject("Deployment", "FOO-1"))) // matching is case-insensitive + a.True(f.ObjectFilter(newTestObject("ConfigMap", "bar-2"))) + a.False(f.ObjectFilter(newTestObject("Service", "foo-1"))) + a.False(f.ObjectFilter(newTestObject("Deployment", "baz-1"))) +} + +func TestObjectPatternFilterExcludes(t *testing.T) { + f := newTestFilters(t, "--exclude-object-pattern=Deployment/(foo.*)") + a := assert.New(t) + a.True(f.ObjectFilter(newTestObject("ConfigMap", "foo-1"))) + a.True(f.ObjectFilter(newTestObject("Deployment", "bar-1"))) + a.False(f.ObjectFilter(newTestObject("Deployment", "foo-1"))) +} + +func TestObjectPatternFilterNoFilters(t *testing.T) { + f := newTestFilters(t) + a := assert.New(t) + a.True(f.ObjectFilter(newTestObject("Deployment", "foo-1"))) + a.True(f.ObjectFilter(newTestObject("Service", "bar-1"))) +} + +func TestObjectPatternFilterInvalid(t *testing.T) { + _, err := newTestFiltersErr(t, "--object-pattern=([") + require.NotNil(t, err) + require.Contains(t, err.Error(), `invalid --object-pattern regexp`) +} diff --git a/internal/model/filters.go b/internal/model/filters.go index 83935e3e..b18082ad 100644 --- a/internal/model/filters.go +++ b/internal/model/filters.go @@ -16,6 +16,7 @@ package model import ( "fmt" + "regexp" "github.com/pkg/errors" "github.com/spf13/pflag" @@ -31,6 +32,7 @@ type Namespaced interface { type Filters struct { includes []string excludes []string + objectPatternFilter Filter excludeClusterObjects bool kindFilter Filter componentFilter Filter @@ -39,12 +41,14 @@ type Filters struct { // NewFilters sets up options in the supplied flags and returns a function to return filters. func NewFilters(flags *pflag.FlagSet, includeAllFilters bool) func() (Filters, error) { - var includes, excludes, kindIncludes, kindExcludes, nsIncludes, nsExcludes []string + var includes, excludes, kindIncludes, kindExcludes, nsIncludes, nsExcludes, regexpObjectIncludes, regexpObjectExcludes []string var includeClusterScopedObjects bool flags.StringArrayVarP(&includes, "component", "c", nil, "include just this component") flags.StringArrayVarP(&excludes, "exclude-component", "C", nil, "exclude this component") if includeAllFilters { + flags.StringArrayVarP(®expObjectIncludes, "object-pattern", "t", nil, "include k8s objects whose Kind/Name matches this regexp") + flags.StringArrayVarP(®expObjectExcludes, "exclude-object-pattern", "T", nil, "exclude k8s objects whose Kind/Name matches this regexp") flags.StringArrayVarP(&kindIncludes, "kind", "k", nil, "include objects with this kind") flags.StringArrayVarP(&kindExcludes, "exclude-kind", "K", nil, "exclude objects with this kind") flags.StringArrayVarP(&nsIncludes, "include-namespace", "p", nil, "include objects with this namespace") @@ -69,9 +73,31 @@ func NewFilters(flags *pflag.FlagSet, includeAllFilters bool) func() (Filters, e includeClusterScopedObjects = false } } + + compilePatterns := func(patterns []string, flag string) ([]*regexp.Regexp, error) { + ret := make([]*regexp.Regexp, 0, len(patterns)) + for _, pattern := range patterns { + compiled, err := regexp.Compile(fmt.Sprintf(`(?i)^(?:%s)$`, pattern)) + if err != nil { + return nil, errors.Wrapf(err, "invalid --%s regexp %q", flag, pattern) + } + ret = append(ret, compiled) + } + return ret, nil + } + regexpIncludes, err := compilePatterns(regexpObjectIncludes, "object-pattern") + if err != nil { + return Filters{}, err + } + regexpExcludes, err := compilePatterns(regexpObjectExcludes, "exclude-object-pattern") + if err != nil { + return Filters{}, err + } + return Filters{ includes: includes, excludes: excludes, + objectPatternFilter: newRegexpFilter(regexpIncludes, regexpExcludes), kindFilter: of, componentFilter: cf, namespaceFilter: nf, @@ -95,6 +121,18 @@ func (f Filters) GVKFilter(gvk schema.GroupVersionKind) bool { return f.kindFilter != nil && f.kindFilter.ShouldInclude(gvk.Kind) } +// ObjectFilter returns true if the object matches the object pattern filters. Matching is +// case-insensitive against the Kind/Name of the object, with each pattern anchored to both ends. +// When several include patterns are supplied, they are combined with a logical OR (union semantics). +// This code was inspired and adapted from grafana tanka +// https://github.com/grafana/tanka/blob/a6a63ac17f713d5fd64bb6d7972bc84c6b5902a6/pkg/process/filter.go#L74 +func (f Filters) ObjectFilter(object K8sQbecMeta) bool { + if f.objectPatternFilter == nil || !f.objectPatternFilter.HasFilters() { + return true + } + return f.objectPatternFilter.ShouldInclude(object.GetKind() + "/" + object.GetName()) +} + // HasNamespaceFilters returns true if filters based on namespace scope are in effect. func (f Filters) HasNamespaceFilters() bool { return (f.namespaceFilter != nil && f.namespaceFilter.HasFilters()) || f.excludeClusterObjects @@ -103,6 +141,9 @@ func (f Filters) HasNamespaceFilters() bool { // Match returns true if the current filters match the supplied object. The client can be nil // if namespace scope filters are not in effect. func (f Filters) Match(o K8sQbecMeta, client Namespaced, defaultNS string) (bool, error) { + if !f.ObjectFilter(o) { + return false, nil + } if f.HasNamespaceFilters() && client == nil { return false, fmt.Errorf("no namespace metadata when namespace filters present") } diff --git a/site/content/userguide/usage/commands.md b/site/content/userguide/usage/commands.md index 32aaa7ed..cd9e7479 100644 --- a/site/content/userguide/usage/commands.md +++ b/site/content/userguide/usage/commands.md @@ -110,6 +110,21 @@ cluster scoped objects. *Note:* specifying namespace / cluster-scope filters requires qbec to access the cluster in order to retrieve metadata on object kinds. This means that a `qbec show` command that normally does not need cluster access will now require it. +### Object pattern filters + +Object pattern filters allow you to filter objects by matching their `Kind/Name` string against a regular expression. + +To include objects matching a pattern, use `-t pattern1 -t pattern2 ...` + +To exclude objects matching a pattern, use `-T pattern1 -T pattern2 ...` + +Patterns are matched case-insensitively against the `Kind/Name` string of every object and are anchored to both ends. +For instance, `-t 'Deployment/(redis.*)'` matches all objects of kind `Deployment` whose name starts with `redis`. + +When multiple include patterns are specified, an object is included if it matches any of them. Similarly, when multiple +exclude patterns are specified, an object is excluded if it matches any of them. Unlike kind and component filters, +include and exclude patterns can be combined; objects matching an exclude pattern are always excluded. + ## Command help Help and examples for every sub-command can be displayed with a `--help` flag. @@ -139,19 +154,28 @@ qbec show dev -k deployment -k configmap # show all objects except secrets qbec show dev -K secret +# show only deployments whose name starts with 'redis' +qbec show dev -t 'Deployment/(redis.*)' + # list all objects for the dev environment qbec show dev -O Flags: - -c, --component stringArray include just this component - -C, --exclude-component stringArray exclude this component - -K, --exclude-kind stringArray exclude objects with this kind - -o, --format string Output format. Supported values are: json, yaml (default "yaml") - -h, --help help for show - -k, --kind stringArray include objects with this kind - -O, --objects Only print names of objects instead of their contents - -S, --show-secrets do not obfuscate secret values in the output - --sort-apply sort output in apply order (requires cluster access) + --clean do not display qbec-generated labels and annotations + -c, --component stringArray include just this component + -C, --exclude-component stringArray exclude this component + -K, --exclude-kind stringArray exclude objects with this kind + -P, --exclude-namespace stringArray exclude objects with this namespace + -T, --exclude-object-pattern stringArray exclude k8s objects whose Kind/Name matches this regexp + -o, --format string Output format. Supported values are: json, yaml (default "yaml") + -h, --help help for show + --include-cluster-objects include cluster scoped objects, false by default when namespace filters present (default true) + -p, --include-namespace stringArray include objects with this namespace + -k, --kind stringArray include objects with this kind + -t, --object-pattern stringArray include k8s objects whose Kind/Name matches this regexp + -O, --objects Only print names of objects instead of their contents + -S, --show-secrets do not obfuscate secret values in the output + --sort-apply sort output in apply order (requires cluster access) Use "qbec options" for a list of global options available to all commands. ```