Repository: plexsystems/konstraint (go)

Issue:
Add support for excludedNamespaces in matcher 
Following the Gatekeeper specification: https://open-policy-agent.github.io/gatekeeper/website/docs/howto/#the-match-field 
In addition to @parameter, please add an option @excludedNamespaces in the `match` block when creating Constraints.

Pull request description:
Add @namespaces and @excludedNamespaces matchers
Adds tags to enable the use of the `namespaces` and `excludedNamespaces`
matchers supported by Gatekeeper.

https://open-policy-agent.github.io/gatekeeper/website/docs/howto/#the-match-field

Signed-off-by: James Alseth <james@jalseth.me>

---

Fixes #244

Patch:
diff --git a/docs/cli/konstraint_create.md b/docs/cli/konstraint_create.md
index 04f6d92..000ab8c 100644
--- a/docs/cli/konstraint_create.md
+++ b/docs/cli/konstraint_create.md
@@ -22,11 +22,12 @@ Create constraints with the Gatekeeper enforcement action set to dryrun
 ### Options
 
 ```
-  -d, --dryrun                Sets the enforcement action of the constraints to dryrun, overriding the @enforcement tag
-  -h, --help                  help for create
-  -o, --output string         Specify an output directory for the Gatekeeper resources
-      --partial-constriants   Generate partial Constraints for policies with parameters
-      --skip-constraints      Skip generation of constraints
+      --constraint-template-version string   Set the version of ConstraintTemplates (default "v1beta1")
+  -d, --dryrun                               Sets the enforcement action of the constraints to dryrun, overriding the @enforcement tag
+  -h, --help                                 help for create
+  -o, --output string                        Specify an output directory for the Gatekeeper resources
+      --partial-constraints                  Generate partial Constraints for policies with parameters
+      --skip-constraints                     Skip generation of constraints
 ```
 
 ### SEE ALSO
diff --git a/docs/cli/konstraint_doc.md b/docs/cli/konstraint_doc.md
index b5def1b..b032ce9 100644
--- a/docs/cli/konstraint_doc.md
+++ b/docs/cli/konstraint_doc.md
@@ -14,7 +14,7 @@ Generate the documentation
 
 Save the documentation to a specific directory
 	konstraint doc --output docs/policies.md
-	
+
 Set the URL where the policies are hosted at
 	konstraint doc --url https://github.com/plexsystems/konstraint
 ```
diff --git a/docs/constraint_creation.md b/docs/constraint_creation.md
index 25d8f98..02aced1 100644
--- a/docs/constraint_creation.md
+++ b/docs/constraint_creation.md
@@ -87,6 +87,22 @@ The comment block is also what is used when generating documentation via the `do
     # @matchlabels app.kubernetes.io/name=mysql app.kubernetes.io/version=5.8
     ```
 
+- `@matchExpression` generates `constraint.spec.match.labelSelector.matchExpressions`. The allowed format is `<key> <operator> <values>` where `key` is the name of the label to operate on, `operator` is one of `In`, `NotIn`, `Exists`, or `DoesNotExist`. The `values` field is an commaa separated field for the `In` and `NotIn` operators. More than one `@matchExpression` annotations can be supplied, each on their own line.
+    ```
+    # @matchExpression app.kubernetes.io/name In mysql,postgres
+    # @matchExpression foobarbaz Exists
+    ```
+
+- `@namespaces` generates `constraint.spec.match.namespaces`. The allowed format is space-separated list of namespaces.
+    ```
+    # @namespaces dev stage prod
+    ```
+
+- `@excludedNamespaces` generates `constraint.spec.match.excludedNamespaces`. The allowed format is a space-separated list of namespaces.
+    ```
+    # @excludedNamespaces kube-system gatekeeper-system
+    ```
+
 ### Skipping generation of the Constraint resource
 
 In some scenarios, you may wish for Konstraint to skip the generation of the `Constraint` resource for a policy and manage that externally. To do so, add the `@skip-constraint` tag in the header comment block.
diff --git a/go.mod b/go.mod
index da85f02..18da7a0 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@ require (
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20210826220005-b48c857c3a0e // indirect
 	github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
+	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
 	github.com/go-logr/logr v1.2.3 // indirect
 	github.com/go-openapi/jsonpointer v0.19.5 // indirect
@@ -40,6 +41,7 @@ require (
 	github.com/pelletier/go-toml v1.9.4 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/spf13/afero v1.6.0 // indirect
 	github.com/spf13/cast v1.4.1 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index a914e63..ed4685b 100644
--- a/go.sum
+++ b/go.sum
@@ -133,8 +133,10 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
 github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -536,8 +538,10 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqn
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
 github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
diff --git a/internal/commands/create.go b/internal/commands/create.go
index 43fdb7f..7af15d2 100644
--- a/internal/commands/create.go
+++ b/internal/commands/create.go
@@ -294,6 +294,18 @@ func getConstraint(violation rego.Rego) (unstructured.Unstructured, error) {
 		}
 	}
 
+	if len(matchers.NamespaceMatcher) > 0 {
+		if err := setNestedStringSlice(&constraint, matchers.NamespaceMatcher, "spec", "match", "namespaces"); err != nil {
+			return unstructured.Unstructured{}, fmt.Errorf("set namespace matcher: %w", err)
+		}
+	}
+
+	if len(matchers.ExcludedNamespaceMatcher) > 0 {
+		if err := setNestedStringSlice(&constraint, matchers.ExcludedNamespaceMatcher, "spec", "match", "excludedNamespaces"); err != nil {
+			return unstructured.Unstructured{}, fmt.Errorf("set namespace matcher: %w", err)
+		}
+	}
+
 	if len(violation.Parameters()) > 0 && viper.GetBool("partial-constraints") {
 		if err := addParametersToConstraint(&constraint, violation.Parameters()); err != nil {
 			return unstructured.Unstructured{}, fmt.Errorf("add parameters %v to constraint: %w", violation.Parameters(), err)
@@ -378,6 +390,14 @@ func setMatchExpressionsMatcher(constraint *unstructured.Unstructured, matcher [
 	return unstructured.SetNestedSlice(constraint.Object, unmarshalled, "spec", "match", "labelSelector", "matchExpressions")
 }
 
+func setNestedStringSlice(constraint *unstructured.Unstructured, slice []string, path ...string) error {
+	var values []interface{}
+	for _, s := range slice {
+		values = append(values, interface{}(s))
+	}
+	return unstructured.SetNestedSlice(constraint.Object, values, path...)
+}
+
 func isValidEnforcementAction(action string) bool {
 	for _, a := range []string{"deny", "dryrun", "warn"} {
 		if a == action {
diff --git a/internal/commands/document.go b/internal/commands/document.go
index 4581eb0..deb1c33 100644
--- a/internal/commands/document.go
+++ b/internal/commands/document.go
@@ -41,7 +41,7 @@ func newDocCommand() *cobra.Command {
 
 Save the documentation to a specific directory
 	konstraint doc --output docs/policies.md
-	
+
 Set the URL where the policies are hosted at
 	konstraint doc --url https://github.com/plexsystems/konstraint`,
 
@@ -95,7 +95,7 @@ func runDocCommand(path string) error {
 		return fmt.Errorf("parsing template: %w", err)
 	}
 
-	f, err := os.OpenFile(viper.GetString("output"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
+	f, err := os.OpenFile(viper.GetString("output"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
 	if err != nil {
 		return fmt.Errorf("opening file for writing: %w", err)
 	}
diff --git a/internal/rego/matchers.go b/internal/rego/matchers.go
index 389aeb7..e35eb2b 100644
--- a/internal/rego/matchers.go
+++ b/internal/rego/matchers.go
@@ -7,9 +7,11 @@ import (
 
 // Matchers are all of the matchers that can be applied to constraints.
 type Matchers struct {
-	KindMatchers            KindMatchers
-	MatchLabelsMatcher      MatchLabelsMatcher
-	MatchExpressionsMatcher []MatchExpressionMatcher
+	KindMatchers             KindMatchers
+	MatchLabelsMatcher       MatchLabelsMatcher
+	MatchExpressionsMatcher  []MatchExpressionMatcher
+	NamespaceMatcher         []string
+	ExcludedNamespaceMatcher []string
 }
 
 // KindMatchers is the slice of KindMatcher
@@ -77,6 +79,22 @@ func (r Rego) Matchers() (Matchers, error) {
 			}
 			matchers.MatchExpressionsMatcher = append(matchers.MatchExpressionsMatcher, m)
 		}
+
+		if commentStartsWith(comment, "@namespaces") {
+			m, err := getStringListMatcher("@namespaces", comment)
+			if err != nil {
+				return Matchers{}, fmt.Errorf("get match namespaces matcher: %w", err)
+			}
+			matchers.NamespaceMatcher = append(matchers.NamespaceMatcher, m...)
+		}
+
+		if commentStartsWith(comment, "@excludedNamespaces") {
+			m, err := getStringListMatcher("@excludedNamespaces", comment)
+			if err != nil {
+				return Matchers{}, fmt.Errorf("get match excludedNamespaces matcher: %w", err)
+			}
+			matchers.ExcludedNamespaceMatcher = append(matchers.ExcludedNamespaceMatcher, m...)
+		}
 	}
 
 	return matchers, nil
@@ -136,6 +154,19 @@ func getMatchExperssionsMatcher(comment string) (MatchExpressionMatcher, error)
 	return matcher, nil
 }
 
+func getStringListMatcher(tag, comment string) ([]string, error) {
+	argSpit := strings.SplitAfter(comment, tag)
+	if len(argSpit) == 0 {
+		return nil, fmt.Errorf("no match for tag %q in comment %q", tag, comment)
+	}
+	lineSplit := strings.Split(strings.TrimSpace(argSpit[1]), " ")
+	if len(lineSplit) == 1 {
+		return nil, fmt.Errorf("no values provided for tag: %s", tag)
+	}
+
+	return lineSplit, nil
+}
+
 func commentStartsWith(comment string, keyword string) bool {
 	return strings.HasPrefix(strings.TrimSpace(comment), keyword)
 }


Test patch:
diff --git a/internal/rego/matchers_test.go b/internal/rego/matchers_test.go
index c48baae..432bdfc 100644
--- a/internal/rego/matchers_test.go
+++ b/internal/rego/matchers_test.go
@@ -112,3 +112,42 @@ func TestGetMatchExpressionsMatcher(t *testing.T) {
 		})
 	}
 }
+
+func TestGetStringListMatcher(t *testing.T) {
+	testCases := []struct {
+		desc      string
+		tag       string
+		comment   string
+		expected  []string
+		wantError bool
+	}{
+		{
+			desc:      "InvalidTag",
+			wantError: true, // will error without a match on the tag
+		},
+		{
+			desc:      "NoValuesSupplied",
+			tag:       "@foo",
+			comment:   "@foo     ",
+			wantError: true,
+		},
+		{
+			desc:     "Valid",
+			tag:      "@foo",
+			comment:  "@foo bar baz",
+			expected: []string{"bar", "baz"},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			actual, err := getStringListMatcher(tc.tag, tc.comment)
+			if (err != nil && !tc.wantError) || (err == nil && tc.wantError) {
+				t.Errorf("Unexpected error state, have %v want %v", !tc.wantError, tc.wantError)
+			}
+			if !reflect.DeepEqual(actual, tc.expected) {
+				t.Errorf("Unexpected values, have %v want %v", actual, tc.expected)
+			}
+		})
+	}
+}
diff --git a/test/constraint_Test.yaml b/test/constraint_Test.yaml
index 69f7124..12007ba 100755
--- a/test/constraint_Test.yaml
+++ b/test/constraint_Test.yaml
@@ -4,6 +4,9 @@ metadata:
   name: test
 spec:
   match:
+    excludedNamespaces:
+    - kube-system
+    - gatekeeper-system
     kinds:
     - apiGroups:
       - apps
@@ -22,3 +25,7 @@ spec:
         - baz
       - key: doggos
         operator: Exists
+    namespaces:
+    - dev
+    - stage
+    - prod
diff --git a/test/src.rego b/test/src.rego
index 4af4a47..322b2bb 100644
--- a/test/src.rego
+++ b/test/src.rego
@@ -5,6 +5,8 @@
 # @kinds apps/DaemonSet apps/Deployment apps/StatefulSet core/Pod
 # @matchExpression foo In bar,baz
 # @matchExpression doggos Exists
+# @namespaces dev stage prod
+# @excludedNamespaces kube-system gatekeeper-system
 package test
 
 import future.keywords