From e20ab46cc42659a5dbc7cbf7d0cc32b56e8e8f67 Mon Sep 17 00:00:00 2001 From: alliasgher Date: Sat, 25 Apr 2026 19:25:52 +0500 Subject: [PATCH] feat(test): support glob patterns in --namespace Allow --namespace to accept glob patterns (`*`, `?`, character classes) so users with hierarchical Rego packages can match families of namespaces in one flag, e.g. --namespace k8s.simple.*. Patterns are expanded against the namespaces actually loaded into the engine, with literals passing through unchanged. An unmatched pattern errors so typos do not silently turn into a no-op. Fixes #1141 Signed-off-by: alliasgher --- internal/commands/test.go | 2 +- runner/test.go | 60 +++++++++++++++++++++++++ runner/test_test.go | 92 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 runner/test_test.go diff --git a/internal/commands/test.go b/internal/commands/test.go index ffbf06de7..8addc319f 100644 --- a/internal/commands/test.go +++ b/internal/commands/test.go @@ -191,7 +191,7 @@ func NewTestCommand(ctx context.Context) *cobra.Command { cmd.Flags().StringSliceP("policy", "p", []string{"policy"}, "Path to the Rego policy files directory") cmd.Flags().StringSliceP("update", "u", []string{}, "A list of URLs can be provided to the update flag, which will download before the tests run") - cmd.Flags().StringSliceP("namespace", "n", []string{"main"}, "Test policies in a specific namespace") + cmd.Flags().StringSliceP("namespace", "n", []string{"main"}, "Test policies in a specific namespace; supports glob patterns matched against loaded namespaces (e.g. \"k8s.simple.*\")") cmd.Flags().StringSliceP("data", "d", []string{}, "A list of paths from which data for the rego policies will be recursively loaded") cmd.Flags().StringSlice("proto-file-dirs", []string{}, "A list of directories containing Protocol Buffer definitions") diff --git a/runner/test.go b/runner/test.go index 48788c9c8..45dff6270 100644 --- a/runner/test.go +++ b/runner/test.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "os" + "path" "path/filepath" "regexp" + "strings" "github.com/open-policy-agent/conftest/downloader" "github.com/open-policy-agent/conftest/output" @@ -89,6 +91,11 @@ func (t *TestRunner) Run(ctx context.Context, fileList []string) (output.CheckRe namespaces := t.Namespace if t.AllNamespaces { namespaces = engine.Namespaces() + } else if hasGlob(namespaces) { + namespaces, err = expandNamespaceGlobs(namespaces, engine.Namespaces()) + if err != nil { + return nil, fmt.Errorf("expand namespace globs: %w", err) + } } var results output.CheckResults @@ -113,6 +120,59 @@ func (t *TestRunner) Run(ctx context.Context, fileList []string) (output.CheckRe return results, nil } +// hasGlob reports whether any namespace pattern contains a glob meta-character. +func hasGlob(patterns []string) bool { + for _, p := range patterns { + if strings.ContainsAny(p, "*?[") { + return true + } + } + return false +} + +// expandNamespaceGlobs expands any glob patterns in patterns against the set +// of namespaces actually loaded into the engine. Non-glob patterns are passed +// through unchanged so users can mix literals and globs (e.g. +// --namespace main --namespace k8s.simple.*). Patterns are matched with +// path.Match semantics after substituting "." for "/" so that "*" matches a +// single dotted segment. +func expandNamespaceGlobs(patterns, available []string) ([]string, error) { + seen := make(map[string]struct{}) + var result []string + add := func(ns string) { + if _, ok := seen[ns]; ok { + return + } + seen[ns] = struct{}{} + result = append(result, ns) + } + + for _, pattern := range patterns { + if !strings.ContainsAny(pattern, "*?[") { + add(pattern) + continue + } + + slashed := strings.ReplaceAll(pattern, ".", "/") + matched := false + for _, ns := range available { + ok, err := path.Match(slashed, strings.ReplaceAll(ns, ".", "/")) + if err != nil { + return nil, fmt.Errorf("invalid namespace pattern %q: %w", pattern, err) + } + if ok { + add(ns) + matched = true + } + } + if !matched { + return nil, fmt.Errorf("no namespaces matched pattern %q", pattern) + } + } + + return result, nil +} + func parseFileList(fileList []string, ignoreRegex string) ([]string, error) { var files []string for _, file := range fileList { diff --git a/runner/test_test.go b/runner/test_test.go new file mode 100644 index 000000000..91724db5e --- /dev/null +++ b/runner/test_test.go @@ -0,0 +1,92 @@ +package runner + +import ( + "reflect" + "testing" +) + +func TestHasGlob(t *testing.T) { + tests := []struct { + name string + patterns []string + want bool + }{ + {name: "empty", patterns: nil, want: false}, + {name: "literals only", patterns: []string{"main", "k8s.simple"}, want: false}, + {name: "single star", patterns: []string{"k8s.*"}, want: true}, + {name: "question mark", patterns: []string{"k8s.simpl?"}, want: true}, + {name: "char class", patterns: []string{"k8s.[a-z]"}, want: true}, + {name: "mixed", patterns: []string{"main", "k8s.*"}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasGlob(tt.patterns); got != tt.want { + t.Errorf("hasGlob(%v) = %v, want %v", tt.patterns, got, tt.want) + } + }) + } +} + +func TestExpandNamespaceGlobs(t *testing.T) { + available := []string{ + "main", + "k8s.simple.deployment", + "k8s.simple.hpa", + "k8s.simple.pod", + "k8s.combined.deployment", + "data.simple", + } + + tests := []struct { + name string + patterns []string + want []string + wantErr bool + }{ + { + name: "literal passes through", + patterns: []string{"main"}, + want: []string{"main"}, + }, + { + name: "single segment wildcard", + patterns: []string{"k8s.simple.*"}, + want: []string{"k8s.simple.deployment", "k8s.simple.hpa", "k8s.simple.pod"}, + }, + { + name: "wildcard does not cross dot boundaries", + patterns: []string{"k8s.*"}, + want: nil, + wantErr: true, + }, + { + name: "char class", + patterns: []string{"k8s.simple.[hp]*"}, + want: []string{"k8s.simple.hpa", "k8s.simple.pod"}, + }, + { + name: "mix of literal and glob, dedupes", + patterns: []string{"main", "k8s.simple.*", "k8s.simple.pod"}, + want: []string{"main", "k8s.simple.deployment", "k8s.simple.hpa", "k8s.simple.pod"}, + }, + { + name: "no match errors", + patterns: []string{"missing.*"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := expandNamespaceGlobs(tt.patterns, available) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +}