diff --git a/.github/workflows/cron_e2e.yaml b/.github/workflows/cron_e2e.yaml index 2f81be09..c6c5ea7d 100644 --- a/.github/workflows/cron_e2e.yaml +++ b/.github/workflows/cron_e2e.yaml @@ -61,11 +61,14 @@ jobs: with: name: konstraint - - name: generate resources + - name: generate resources (v0) run: | chmod +x ./konstraint ./konstraint create -o e2e-resources examples + - name: generate resources (v1) + run: ./konstraint create -o e2e-resources-v1 --rego-version v1 examples + - name: create kind cluster run: kind create cluster @@ -77,9 +80,23 @@ jobs: kubectl create ns gatekeeper-system helm install gatekeeper gk/gatekeeper -n gatekeeper-system --set replicas=1 --version ${GK_VERSION} --set psp.enabled=false - - name: apply resources + - name: apply resources (v0) working-directory: e2e-resources run: | for ct in $(ls template*); do kubectl apply -f $ct; done sleep 60 # gatekeeper takes some time to create the CRDs for c in $(ls constraint*); do kubectl apply -f $c; done + + - name: cleanup resources (v0) + working-directory: e2e-resources + run: | + for c in $(ls constraint*); do kubectl delete -f $c; done + for ct in $(ls template*); do kubectl delete -f $ct; done + sleep 60 + + - name: apply resources (v1) + working-directory: e2e-resources-v1 + run: | + for ct in $(ls template*); do kubectl apply -f $ct; done + sleep 60 # gatekeeper takes some time to create the CRDs + for c in $(ls constraint*); do kubectl apply -f $c; done diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index c95e68eb..c1da309a 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -130,9 +130,12 @@ jobs: with: version: latest - - name: opa check strict + - name: opa check strict (v0) run: opa check --v0-compatible --strict --ignore "*.yaml" examples + - name: opa check strict (v1) + run: opa check --strict --ignore "*.yaml" examples + - name: setup regal uses: styrainc/setup-regal@v1.0.0 with: @@ -190,11 +193,15 @@ jobs: with: name: konstraint-ubuntu-latest - - name: generate resources + - name: generate resources (v0) run: | chmod +x ./konstraint ./konstraint create -o e2e-resources examples + - name: generate resources (v1) + run: | + ./konstraint create -o e2e-resources-v1 --rego-version v1 examples + - name: create kind cluster run: kind create cluster @@ -206,9 +213,23 @@ jobs: kubectl create ns gatekeeper-system helm install gatekeeper gk/gatekeeper -n gatekeeper-system --set replicas=1 --version ${GK_VERSION} --set psp.enabled=false - - name: apply resources + - name: apply resources (v0) working-directory: e2e-resources run: | for ct in $(ls template*); do kubectl apply -f $ct; done sleep 60 # gatekeeper takes some time to create the CRDs for c in $(ls constraint*); do kubectl apply -f $c; done + + - name: cleanup resources (v0) + working-directory: e2e-resources + run: | + for c in $(ls constraint*); do kubectl delete -f $c --ignore-not-found; done + for ct in $(ls template*); do kubectl delete -f $ct --ignore-not-found; done + sleep 30 # wait for cleanup + + - name: apply resources (v1) + working-directory: e2e-resources-v1 + run: | + for ct in $(ls template*); do kubectl apply -f $ct; done + sleep 60 # gatekeeper takes some time to create the CRDs + for c in $(ls constraint*); do kubectl apply -f $c; done diff --git a/README.md b/README.md index c33bbf5f..0d45b8e4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ To create the Gatekeeper resources, use `konstraint create `. To generate the accompanying documentation, use `konstraint doc `. -Both commands support the `--output` flag to specify where to save the output. For more detailed usage documentation, see the [CLI Documentation](docs/cli/konstraint.md). +Both commands support the `--output` flag to specify where to save the output. Use `--rego-version v1` to generate OPA Rego v1 compatible ConstraintTemplates with the `code` field structure. For more detailed usage documentation, see the [CLI Documentation](docs/cli/konstraint.md). ## Why this tool exists diff --git a/docs/cli/konstraint_create.md b/docs/cli/konstraint_create.md index 0c686572..ef0ee36d 100644 --- a/docs/cli/konstraint_create.md +++ b/docs/cli/konstraint_create.md @@ -30,7 +30,9 @@ Create constraints with the Gatekeeper enforcement action set to dryrun --log-level string Set a log level. Options: error, info, debug, trace (default "info") -o, --output string Specify an output directory for the Gatekeeper resources --partial-constraints Generate partial Constraints for policies with parameters + --rego-version string Set the Rego version for parsing and template generation (v0, v1) (default "v0") --skip-constraints Skip generation of constraints + --strip-v0-imports Strip v0 compatibility imports from generated templates: import future.keywords[.if|.in|.every|.contains], import rego.v1 (only valid with --rego-version v1) ``` ### SEE ALSO diff --git a/docs/cli/konstraint_doc.md b/docs/cli/konstraint_doc.md index b059759d..160b204d 100644 --- a/docs/cli/konstraint_doc.md +++ b/docs/cli/konstraint_doc.md @@ -26,6 +26,8 @@ Set the URL where the policies are hosted at --include-comments Include comments from the rego source in the documentation --no-rego Do not include the Rego in the policy documentation -o, --output string Output location (including filename) for the policy documentation (default "policies.md") + --rego-version string Rego version for parsing policies (v0, v1) (default "v0") + --strip-v0-imports Strip v0 compatibility imports from documentation: import future.keywords[.if|.in|.every|.contains], import rego.v1 (only valid with --rego-version v1) --template-file string File to read the template from (default: "") --url string The URL where the policy files are hosted at (e.g. https://github.com/policies) ``` diff --git a/docs/constraint_creation.md b/docs/constraint_creation.md index 944e6af6..fc728c86 100644 --- a/docs/constraint_creation.md +++ b/docs/constraint_creation.md @@ -169,3 +169,95 @@ You can optionally specify annotations and labels for the generated Constraint. # "argocd.argoproj.io/sync-options": "SkipDryRunOnMissingResource=true" ... ``` + +## Documentation Links + +You can specify documentation links using the `links` annotation. Both single string and array formats are supported: + +```rego +# METADATA +# title: Example Policy +# custom: +# links: +# - https://kubernetes.io/docs/example1/ +# - https://kubernetes.io/docs/example2/ +``` + +In custom templates, access via `{{ .Policy.Links }}` which returns a `[]string`. + +## Sync Data for Referential Constraints + +For policies that require data from other Kubernetes resources, use the `syncData` annotation to specify what Gatekeeper should cache. This populates the `metadata.gatekeeper.sh/requires-sync-data` annotation. + +```rego +# METADATA +# title: Unique Ingress Host +# custom: +# syncData: +# - groups: ["networking.k8s.io"] +# versions: ["v1"] +# kinds: ["Ingress"] +``` + +For complex AND/OR logic, use nested arrays: + +```rego +# custom: +# syncData: +# - - groups: ["extensions"] # OR group 1 +# versions: ["v1beta1"] +# kinds: ["Ingress"] +# - groups: ["networking.k8s.io"] +# versions: ["v1"] +# kinds: ["Ingress"] +# - - groups: ["storage.k8s.io"] # AND with above +# versions: ["v1"] +# kinds: ["StorageClass"] +``` + +The format is `[ [{}OR{}] AND [{}OR{}] ]`. In custom templates, use `{{ .Policy.SyncDataJSON }}` for the properly formatted annotation value. + +## Multiple Constraint Configurations + +To document multiple constraint variations from a single policy template, use the `constraints` annotation: + +```rego +# METADATA +# title: Resource Limits +# custom: +# constraints: +# - name: prod-deployments +# description: Strict limits for production +# enforcement: deny +# kinds: +# - apiGroups: +# - apps +# kinds: +# - Deployment +# namespaces: +# - production +# parameters: +# maxReplicas: 5 +# - name: dev-deployments +# enforcement: warn +# kinds: +# - apiGroups: +# - apps +# kinds: +# - Deployment +# excludedNamespaces: +# - kube-system +# parameters: +# maxReplicas: 100 +``` + +Each constraint config supports: +- `name` - constraint name +- `description` - optional description +- `enforcement` - `deny`, `warn`, or `dryrun` +- `kinds` - kind matchers (same format as policy-level matchers) +- `namespaces` - namespace list +- `excludedNamespaces` - excluded namespace list +- `parameters` - parameter values + +In custom templates, iterate with `{{ range .Policy.Constraints }}`. diff --git a/internal/commands/constrainttemplate_template.tpl b/internal/commands/constrainttemplate_template.tpl index 474e4355..2b48e91c 100644 --- a/internal/commands/constrainttemplate_template.tpl +++ b/internal/commands/constrainttemplate_template.tpl @@ -14,8 +14,19 @@ spec: properties: {{- .AnnotationParameters | toJSON | fromJSON | toIndentYAML 2 | nindent 12 }} {{- end }} targets: + {{- if eq .Version.String "v1" }} + - code: + - engine: Rego + source: + libs: {{- range .RenderedDependencies }} + - |- {{- . | nindent 10 }} + {{- end }} + rego: |- {{- .RenderedSource | nindent 10 }} + version: v1 + {{- else }} - libs: {{- range .Dependencies }} - |- {{- . | nindent 6 -}} {{ end }} rego: |- {{- .Source | nindent 6 }} + {{- end }} target: admission.k8s.gatekeeper.sh diff --git a/internal/commands/create.go b/internal/commands/create.go index f25940a3..dd7ab852 100644 --- a/internal/commands/create.go +++ b/internal/commands/create.go @@ -2,9 +2,11 @@ package commands import ( "bytes" + "errors" "fmt" "os" "path/filepath" + "slices" "text/template" "github.com/plexsystems/konstraint/internal/rego" @@ -12,6 +14,7 @@ import ( "github.com/go-sprout/sprout/sprigin" v1 "github.com/open-policy-agent/frameworks/constraint/pkg/apis/templates/v1" "github.com/open-policy-agent/frameworks/constraint/pkg/apis/templates/v1beta1" + "github.com/open-policy-agent/frameworks/constraint/pkg/core/templates" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -63,13 +66,25 @@ Create constraints with the Gatekeeper enforcement action set to dryrun return fmt.Errorf("bind log-level flag: %w", err) } + if err := viper.BindPFlag("rego-version", cmd.PersistentFlags().Lookup("rego-version")); err != nil { + return fmt.Errorf("bind rego-version flag: %w", err) + } + + if err := viper.BindPFlag("strip-v0-imports", cmd.PersistentFlags().Lookup("strip-v0-imports")); err != nil { + return fmt.Errorf("bind strip-v0-imports flag: %w", err) + } + + if cmd.PersistentFlags().Lookup("strip-v0-imports").Changed && viper.GetString("rego-version") != "v1" { + return errors.New("--strip-v0-imports can only be used with --rego-version v1") + } + if cmd.PersistentFlags().Lookup("constraint-template-custom-template-file").Changed && cmd.PersistentFlags().Lookup("constraint-template-version").Changed { - return fmt.Errorf("need to set either constraint-template-custom-template-file or constraint-template-version") + return errors.New("need to set either constraint-template-custom-template-file or constraint-template-version") } if cmd.PersistentFlags().Lookup("log-level").Changed { level, err := log.ParseLevel(viper.GetString("log-level")) if err != nil { - return fmt.Errorf("unknown log level: Need to use either error, info, debug or trace") + return errors.New("unknown log level: Need to use either error, info, debug or trace") } log.SetLevel(level) } @@ -90,11 +105,18 @@ Create constraints with the Gatekeeper enforcement action set to dryrun cmd.PersistentFlags().String("constraint-template-custom-template-file", "", "Path to a custom template file to generate constraint templates") cmd.PersistentFlags().String("constraint-custom-template-file", "", "Path to a custom template file to generate constraints") cmd.PersistentFlags().String("log-level", "info", "Set a log level. Options: error, info, debug, trace") + cmd.PersistentFlags().String("rego-version", "v0", "Set the Rego version for parsing and template generation (v0, v1)") + cmd.PersistentFlags().Bool("strip-v0-imports", false, "Strip v0 compatibility imports from generated templates: import future.keywords[.if|.in|.every|.contains], import rego.v1 (only valid with --rego-version v1)") return &cmd } func runCreateCommand(path string) error { - violations, err := rego.GetViolations(path) + regoVersion, err := rego.ParseVersion(viper.GetString("rego-version")) + if err != nil { + return fmt.Errorf("parse rego-version flag: %w", err) + } + + violations, err := rego.GetViolations(path, regoVersion) if err != nil { return fmt.Errorf("get violations: %w", err) } @@ -131,8 +153,9 @@ func runCreateCommand(path string) error { constraintTemplateVersion := viper.GetString("constraint-template-version") constraintTemplateCustomTemplateFile := viper.GetString("constraint-template-custom-template-file") + stripV0Imports := viper.GetBool("strip-v0-imports") - constraintTemplate, err := renderConstraintTemplate(violation, constraintTemplateVersion, constraintTemplateCustomTemplateFile, logger) + constraintTemplate, err := renderConstraintTemplate(violation, constraintTemplateVersion, constraintTemplateCustomTemplateFile, stripV0Imports, logger) if err != nil { return fmt.Errorf("rendering ConstraintTemplate: %w", err) } @@ -169,7 +192,7 @@ func runCreateCommand(path string) error { return nil } -func renderConstraintTemplate(violation rego.Rego, constraintTemplateVersion string, constraintTemplateCustomTemplateFile string, logger *log.Entry) ([]byte, error) { +func renderConstraintTemplate(violation rego.Rego, constraintTemplateVersion string, constraintTemplateCustomTemplateFile string, stripV0Imports bool, logger *log.Entry) ([]byte, error) { var constraintTemplate any var constraintTemplateBytes []byte @@ -178,16 +201,16 @@ func renderConstraintTemplate(violation rego.Rego, constraintTemplateVersion str if err != nil { return nil, fmt.Errorf("unable to open/read template file: %w", err) } - constraintTemplateBytes, err = renderTemplate(violation, customTemplate) + constraintTemplateBytes, err = renderTemplate(violation, stripV0Imports, customTemplate) if err != nil { return nil, fmt.Errorf("unable to render custom template: %w", err) } } else { switch constraintTemplateVersion { case "v1": - constraintTemplate = getConstraintTemplatev1(violation, logger) + constraintTemplate = getConstraintTemplatev1(violation, stripV0Imports, logger) case "v1beta1": - constraintTemplate = getConstraintTemplatev1beta1(violation, logger) + constraintTemplate = getConstraintTemplatev1beta1(violation, stripV0Imports, logger) default: return nil, fmt.Errorf("unsupported API version for constrainttemplate: %s", constraintTemplateVersion) } @@ -200,7 +223,6 @@ func renderConstraintTemplate(violation rego.Rego, constraintTemplateVersion str } return constraintTemplateBytes, nil - } func renderConstraint(violation rego.Rego, constraintCustomTemplateFile string, logger *log.Entry) ([]byte, error) { var constraintBytes []byte @@ -209,7 +231,7 @@ func renderConstraint(violation rego.Rego, constraintCustomTemplateFile string, if err != nil { return nil, fmt.Errorf("unable to open/read template file: %w", err) } - constraintBytes, err = renderTemplate(violation, customTemplate) + constraintBytes, err = renderTemplate(violation, false, customTemplate) if err != nil { return nil, fmt.Errorf("unable to render custom constraint: %w", err) } @@ -225,24 +247,51 @@ func renderConstraint(violation rego.Rego, constraintCustomTemplateFile string, } } return constraintBytes, nil +} +type templateData struct { + rego.Rego + stripV0Imports bool } -func renderTemplate(violation rego.Rego, appliedTemplate []byte) ([]byte, error) { +func (t templateData) RenderedSource() string { + if t.stripV0Imports { + return rego.StripV1Imports(t.Source()) + } + return t.Source() +} + +func (t templateData) RenderedDependencies() []string { + deps := t.Dependencies() + if !t.stripV0Imports { + return deps + } + result := make([]string, len(deps)) + for i, dep := range deps { + result[i] = rego.StripV1Imports(dep) + } + return result +} + +func renderTemplate(violation rego.Rego, stripV0Imports bool, appliedTemplate []byte) ([]byte, error) { t, err := template.New("template").Funcs(sprigin.FuncMap()).Parse(string(appliedTemplate)) if err != nil { return nil, fmt.Errorf("parsing template: %w", err) } buf := new(bytes.Buffer) - if err := t.Execute(buf, violation); err != nil { + data := templateData{ + Rego: violation, + stripV0Imports: stripV0Imports, + } + if err := t.Execute(buf, data); err != nil { return nil, fmt.Errorf("executing template: %w", err) } return buf.Bytes(), nil } -func getConstraintTemplatev1(violation rego.Rego, _ *log.Entry) *v1.ConstraintTemplate { +func getConstraintTemplatev1(violation rego.Rego, stripV0Imports bool, _ *log.Entry) *v1.ConstraintTemplate { constraintTemplate := v1.ConstraintTemplate{ TypeMeta: metav1.TypeMeta{ APIVersion: "templates.gatekeeper.sh/v1", @@ -262,13 +311,42 @@ func getConstraintTemplatev1(violation rego.Rego, _ *log.Entry) *v1.ConstraintTe Targets: []v1.Target{ { Target: "admission.k8s.gatekeeper.sh", - Libs: violation.Dependencies(), - Rego: violation.Source(), }, }, }, } + if violation.Version() == rego.V1 { + regoSource := violation.Source() + if stripV0Imports { + regoSource = rego.StripV1Imports(regoSource) + } + source := map[string]any{ + "version": "v1", + "rego": regoSource, + } + if len(violation.Dependencies()) > 0 { + var libs []string + for _, lib := range violation.Dependencies() { + if stripV0Imports { + libs = append(libs, rego.StripV1Imports(lib)) + } else { + libs = append(libs, lib) + } + } + source["libs"] = libs + } + constraintTemplate.Spec.Targets[0].Code = []v1.Code{ + { + Engine: "Rego", + Source: &templates.Anything{Value: source}, + }, + } + } else { + constraintTemplate.Spec.Targets[0].Rego = violation.Source() + constraintTemplate.Spec.Targets[0].Libs = violation.Dependencies() + } + if len(violation.AnnotationParameters()) > 0 { constraintTemplate.Spec.CRD.Spec.Validation = &v1.Validation{ OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ @@ -281,7 +359,7 @@ func getConstraintTemplatev1(violation rego.Rego, _ *log.Entry) *v1.ConstraintTe return &constraintTemplate } -func getConstraintTemplatev1beta1(violation rego.Rego, _ *log.Entry) *v1beta1.ConstraintTemplate { +func getConstraintTemplatev1beta1(violation rego.Rego, stripV0Imports bool, _ *log.Entry) *v1beta1.ConstraintTemplate { constraintTemplate := v1beta1.ConstraintTemplate{ TypeMeta: metav1.TypeMeta{ APIVersion: "templates.gatekeeper.sh/v1beta1", @@ -301,13 +379,42 @@ func getConstraintTemplatev1beta1(violation rego.Rego, _ *log.Entry) *v1beta1.Co Targets: []v1beta1.Target{ { Target: "admission.k8s.gatekeeper.sh", - Libs: violation.Dependencies(), - Rego: violation.Source(), }, }, }, } + if violation.Version() == rego.V1 { + regoSource := violation.Source() + if stripV0Imports { + regoSource = rego.StripV1Imports(regoSource) + } + source := map[string]any{ + "version": "v1", + "rego": regoSource, + } + if len(violation.Dependencies()) > 0 { + var libs []string + for _, lib := range violation.Dependencies() { + if stripV0Imports { + libs = append(libs, rego.StripV1Imports(lib)) + } else { + libs = append(libs, lib) + } + } + source["libs"] = libs + } + constraintTemplate.Spec.Targets[0].Code = []v1beta1.Code{ + { + Engine: "Rego", + Source: &templates.Anything{Value: source}, + }, + } + } else { + constraintTemplate.Spec.Targets[0].Rego = violation.Source() + constraintTemplate.Spec.Targets[0].Libs = violation.Dependencies() + } + if len(violation.AnnotationParameters()) > 0 { constraintTemplate.Spec.CRD.Spec.Validation = &v1beta1.Validation{ OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ @@ -383,11 +490,5 @@ func addParametersToConstraint(constraint *unstructured.Unstructured, parameters } func isValidEnforcementAction(action string) bool { - for _, a := range []string{"deny", "dryrun", "warn"} { - if a == action { - return true - } - } - - return false + return slices.Contains([]string{"deny", "dryrun", "warn"}, action) } diff --git a/internal/commands/create_test.go b/internal/commands/create_test.go index b95447ee..c7b00cb5 100644 --- a/internal/commands/create_test.go +++ b/internal/commands/create_test.go @@ -85,7 +85,7 @@ func TestRenderConstraintTemplate(t *testing.T) { // Need to remove carriage return for testing on windows expected = bytes.ReplaceAll(expected, []byte("\r"), []byte("")) - actual, err := renderConstraintTemplate(violations[0], "v1", "", entry.LastEntry()) + actual, err := renderConstraintTemplate(violations[0], "v1", "", false, entry.LastEntry()) if err != nil { t.Errorf("Error rendering constrainttemplate: %v", err) } @@ -114,7 +114,37 @@ func TestRenderConstraintTemplateWithCustomTemplate(t *testing.T) { // Need to remove carriage return for testing on Windows expected = bytes.ReplaceAll(expected, []byte("\r"), []byte("")) - actual, err := renderConstraintTemplate(violations[0], "v1", "constrainttemplate_template.tpl", entry.LastEntry()) + actual, err := renderConstraintTemplate(violations[0], "v1", "constrainttemplate_template.tpl", false, entry.LastEntry()) + + if err != nil { + t.Errorf("Error rendering constrainttemplate: %v", err) + } + + // Need to remove carriage return for testing on Windows + actual = bytes.ReplaceAll(actual, []byte("\r"), []byte("")) + + if !bytes.Equal(actual, expected) { + t.Errorf("Unexpected rendered template:\n %v", cmp.Diff(string(expected), string(actual))) + } +} + +func TestRenderConstraintTemplateWithCustomTemplateV1(t *testing.T) { + _, entry := log.NewNullLogger() + + violations, err := GetViolationsV1() + if err != nil { + t.Errorf("Error getting violations: %v", err) + } + + expected, err := os.ReadFile("../../test/output/custom/template_FullMetadata_v1.yaml") + if err != nil { + t.Errorf("Error reading expected file: %v", err) + } + + // Need to remove carriage return for testing on Windows + expected = bytes.ReplaceAll(expected, []byte("\r"), []byte("")) + + actual, err := renderConstraintTemplate(violations[0], "v1", "constrainttemplate_template.tpl", true, entry.LastEntry()) if err != nil { t.Errorf("Error rendering constrainttemplate: %v", err) @@ -129,9 +159,124 @@ func TestRenderConstraintTemplateWithCustomTemplate(t *testing.T) { } func GetViolations() ([]rego.Rego, error) { - violations, err := rego.GetViolations("../../test/policies/") + violations, err := rego.GetViolations("../../test/policies/", rego.V0) if err != nil { return nil, err } return violations, nil } + +func GetViolationsV1() ([]rego.Rego, error) { + violations, err := rego.GetViolations("../../test/policies/", rego.V1) + if err != nil { + return nil, err + } + return violations, nil +} + +func TestRenderConstraintTemplateV0Format(t *testing.T) { + _, entry := log.NewNullLogger() + + violations, err := GetViolations() + if err != nil { + t.Fatalf("Error getting violations: %v", err) + } + + if len(violations) == 0 { + t.Fatal("No violations found") + } + + actual, err := renderConstraintTemplate(violations[0], "v1", "", false, entry.LastEntry()) + if err != nil { + t.Fatalf("Error rendering constrainttemplate: %v", err) + } + + if bytes.Contains(actual, []byte("code:")) { + t.Error("v0 template should not contain 'code:' field") + } + if bytes.Contains(actual, []byte("engine: Rego")) { + t.Error("v0 template should not contain 'engine: Rego'") + } + if !bytes.Contains(actual, []byte("libs:")) { + t.Error("v0 template should contain 'libs:' field") + } + if !bytes.Contains(actual, []byte("rego: |")) { + t.Error("v0 template should contain 'rego: |' field") + } +} + +func TestRenderConstraintTemplateV1Format(t *testing.T) { + _, entry := log.NewNullLogger() + + violations, err := GetViolationsV1() + if err != nil { + t.Fatalf("Error getting v1 violations: %v", err) + } + + if len(violations) == 0 { + t.Fatal("No violations found") + } + + actual, err := renderConstraintTemplate(violations[0], "v1", "", true, entry.LastEntry()) + if err != nil { + t.Fatalf("Error rendering constrainttemplate: %v", err) + } + + if !bytes.Contains(actual, []byte("code:")) { + t.Error("v1 template should contain 'code:' field") + } + if !bytes.Contains(actual, []byte("engine: Rego")) { + t.Error("v1 template should contain 'engine: Rego'") + } + if !bytes.Contains(actual, []byte("source:")) { + t.Error("v1 template should contain 'source:' field") + } + if !bytes.Contains(actual, []byte("version: v1")) { + t.Error("v1 template should contain 'version: v1' in source") + } + if bytes.Contains(actual, []byte("import future.keywords")) { + t.Error("v1 template with strip-v0-imports should not contain 'import future.keywords'") + } +} + +func TestRenderConstraintTemplateV1StripImports(t *testing.T) { + _, entry := log.NewNullLogger() + + violations, err := GetViolationsV1() + if err != nil { + t.Fatalf("Error getting v1 violations: %v", err) + } + + if len(violations) == 0 { + t.Fatal("No violations found") + } + + withImports, err := renderConstraintTemplate(violations[0], "v1", "", false, entry.LastEntry()) + if err != nil { + t.Fatalf("Error rendering constrainttemplate: %v", err) + } + + withoutImports, err := renderConstraintTemplate(violations[0], "v1", "", true, entry.LastEntry()) + if err != nil { + t.Fatalf("Error rendering constrainttemplate: %v", err) + } + + for _, actual := range [][]byte{withImports, withoutImports} { + if !bytes.Contains(actual, []byte("code:")) { + t.Error("v1 template should contain 'code:' field") + } + if !bytes.Contains(actual, []byte("engine: Rego")) { + t.Error("v1 template should contain 'engine: Rego'") + } + if !bytes.Contains(actual, []byte("version: v1")) { + t.Error("v1 template should contain 'version: v1' in source") + } + } + + if bytes.Contains(withoutImports, []byte("import future.keywords")) { + t.Error("v1 template with strip-v0-imports=true should not contain 'import future.keywords'") + } + if bytes.Contains(withoutImports, []byte("import rego.v1")) { + t.Error("v1 template with strip-v0-imports=true should not contain 'import rego.v1'") + } +} diff --git a/internal/commands/document.go b/internal/commands/document.go index 2116bbe4..06adc247 100644 --- a/internal/commands/document.go +++ b/internal/commands/document.go @@ -2,6 +2,7 @@ package commands import ( _ "embed" + "errors" "fmt" "os" "path/filepath" @@ -99,6 +100,18 @@ Set the URL where the policies are hosted at return fmt.Errorf("bind include-comments flag: %w", err) } + if err := viper.BindPFlag("rego-version", cmd.Flags().Lookup("rego-version")); err != nil { + return fmt.Errorf("bind rego-version flag: %w", err) + } + + if err := viper.BindPFlag("strip-v0-imports", cmd.Flags().Lookup("strip-v0-imports")); err != nil { + return fmt.Errorf("bind strip-v0-imports flag: %w", err) + } + + if cmd.Flags().Lookup("strip-v0-imports").Changed && viper.GetString("rego-version") != "v1" { + return errors.New("--strip-v0-imports can only be used with --rego-version v1") + } + path := "." if len(args) > 0 { path = args[0] @@ -113,6 +126,8 @@ Set the URL where the policies are hosted at cmd.Flags().String("url", "", "The URL where the policy files are hosted at (e.g. https://github.com/policies)") cmd.Flags().Bool("no-rego", false, "Do not include the Rego in the policy documentation") cmd.Flags().Bool("include-comments", false, "Include comments from the rego source in the documentation") + cmd.Flags().String("rego-version", "v0", "Rego version for parsing policies (v0, v1)") + cmd.Flags().Bool("strip-v0-imports", false, "Strip v0 compatibility imports from documentation: import future.keywords[.if|.in|.every|.contains], import rego.v1 (only valid with --rego-version v1)") return &cmd } @@ -125,7 +140,14 @@ func runDocCommand(path string) error { return fmt.Errorf("create output dir: %w", err) } - docs, err := getDocumentation(path, outputDirectory) + regoVersion, err := rego.ParseVersion(viper.GetString("rego-version")) + if err != nil { + return fmt.Errorf("parse rego-version flag: %w", err) + } + + stripV0Imports := viper.GetBool("strip-v0-imports") + + docs, err := getDocumentation(path, outputDirectory, regoVersion, stripV0Imports) if err != nil { return fmt.Errorf("get documentation: %w", err) } @@ -162,8 +184,8 @@ func runDocCommand(path string) error { return nil } -func getDocumentation(path string, outputDirectory string) (map[rego.Severity][]Document, error) { - policies, err := rego.GetAllSeveritiesWithoutImports(path) +func getDocumentation(path string, outputDirectory string, regoVersion rego.Version, stripV0Imports bool) (map[rego.Severity][]Document, error) { + policies, err := rego.GetAllSeveritiesWithoutImports(path, regoVersion) if err != nil { return nil, fmt.Errorf("get all severities: %w", err) } @@ -282,27 +304,31 @@ func getDocumentation(path string, outputDirectory string) (map[rego.Severity][] Parameters: parameters, } - var rego string + var regoSource string if viper.GetBool("include-comments") { - rego = policy.FullSource() + regoSource = policy.FullSource() } else { - rego = policy.Source() + regoSource = policy.Source() + } + if stripV0Imports { + regoSource = rego.StripV1Imports(regoSource) } if viper.GetBool("no-rego") { - rego = "" + regoSource = "" } document := Document{ Header: header, URL: url, - Rego: rego, + Rego: regoSource, Policy: policy, } - if policy.Severity() == "" { + switch { + case policy.Severity() == "": documents["Other"] = append(documents["Other"], document) - } else if policy.Enforcement() == "dryrun" { + case policy.Enforcement() == "dryrun": documents["Not Enforced"] = append(documents["Not Enforced"], document) - } else { + default: documents[policy.Severity()] = append(documents[policy.Severity()], document) } } diff --git a/internal/rego/rego.go b/internal/rego/rego.go index 90470ab4..3a6eaa1f 100644 --- a/internal/rego/rego.go +++ b/internal/rego/rego.go @@ -3,10 +3,12 @@ package rego import ( "bytes" "encoding/json" + "errors" "fmt" "os" "path/filepath" "regexp" + "slices" "sort" "strings" @@ -18,6 +20,38 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// Version represents the Rego language version. +type Version int + +const ( + // V0 is the default Rego version (legacy syntax) + V0 Version = iota + // V1 is the new Rego v1 syntax (OPA v1.0+) + V1 +) + +// String returns the string representation of the Version. +func (v Version) String() string { + switch v { + case V1: + return "v1" + default: + return "v0" + } +} + +// ParseVersion parses a string into a Version. +func ParseVersion(s string) (Version, error) { + switch strings.ToLower(s) { + case "v0": + return V0, nil + case "v1": + return V1, nil + default: + return V0, fmt.Errorf("invalid rego version: %s (must be v0 or v1)", s) + } +} + // Severity describes the severity level of the rego file. type Severity string @@ -40,6 +74,9 @@ const ( annoSkipConstraint = "skipConstraint" annoAnnotations = "annotations" annoLabels = "labels" + annoLinks = "links" + annoSyncData = "syncData" + annoConstraints = "constraints" ) const ( @@ -64,6 +101,7 @@ type Rego struct { skipTemplate bool skipConstraint bool metaData *MetaData + regoVersion Version // Duplicate data from OPA Metadata annotations. annotations *ast.Annotations annoTitle string @@ -73,6 +111,14 @@ type Rego struct { annoNamespaceMatchers []string annoExcludedNamespaceMatchers []string annoLabelSelector *metav1.LabelSelector + annoLinks []string + annoSyncData [][]SyncDataEntry + annoConstraints []ConstraintConfig +} + +// Version returns the Rego language version of this policy. +func (r Rego) Version() Version { + return r.regoVersion } type AnnoKindMatcher struct { @@ -80,6 +126,26 @@ type AnnoKindMatcher struct { Kinds []string `json:"kinds,omitempty"` } +// SyncDataEntry represents a single entry in the syncData annotation +// used for Gatekeeper's metadata.gatekeeper.sh/requires-sync-data annotation. +type SyncDataEntry struct { + Groups []string `json:"groups,omitempty"` + Versions []string `json:"versions,omitempty"` + Kinds []string `json:"kinds,omitempty"` +} + +// ConstraintConfig represents a constraint configuration for documenting +// multiple constraint variations from a single policy template. +type ConstraintConfig struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Enforcement string `json:"enforcement,omitempty"` + Kinds []AnnoKindMatcher `json:"kinds,omitempty"` + Namespaces []string `json:"namespaces,omitempty"` + ExcludedNamespaces []string `json:"excludedNamespaces,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + func (akm AnnoKindMatcher) String() string { var result string for _, apiGroup := range akm.APIGroups { @@ -104,19 +170,19 @@ type Parameter struct { // GetAllSeverities gets all of the rego files found in the given directory as // well as any subdirectories. Only rego files that contain a valid severity // will be returned. -func GetAllSeverities(directory string) ([]Rego, error) { - return getAllSeverities(directory, true) +func GetAllSeverities(directory string, regoVersion Version) ([]Rego, error) { + return getAllSeverities(directory, true, regoVersion) } // GetAllSeveritiesWithoutImports gets all of the Rego files found in the given // directory as well as any subdirectories, but does not attempt to parse the // imports. -func GetAllSeveritiesWithoutImports(directory string) ([]Rego, error) { - return getAllSeverities(directory, false) +func GetAllSeveritiesWithoutImports(directory string, regoVersion Version) ([]Rego, error) { + return getAllSeverities(directory, false, regoVersion) } -func getAllSeverities(directory string, parseImports bool) ([]Rego, error) { - regos, err := parseDirectory(directory, parseImports) +func getAllSeverities(directory string, parseImports bool, regoVersion Version) ([]Rego, error) { + regos, err := parseDirectory(directory, parseImports, regoVersion) if err != nil { return nil, fmt.Errorf("parse directory: %w", err) } @@ -136,8 +202,8 @@ func getAllSeverities(directory string, parseImports bool) ([]Rego, error) { // GetViolations gets all of the files found in the given directory as well as // any subdirectories. Only rego files that have a severity of violation will // be returned. -func GetViolations(directory string) ([]Rego, error) { - regos, err := parseDirectory(directory, true) +func GetViolations(directory string, regoVersion Version) ([]Rego, error) { + regos, err := parseDirectory(directory, true, regoVersion) if err != nil { return nil, fmt.Errorf("parse directory: %w", err) } @@ -179,9 +245,41 @@ func (r Rego) AnnotationParameters() map[string]apiextensionsv1.JSONSchemaProps return r.annoParameters } +// Links returns the links defined in the policy annotations. +// Supports both single string and array of strings in the annotation. +func (r Rego) Links() []string { + return r.annoLinks +} + +// SyncData returns the syncData entries for Gatekeeper's requires-sync-data annotation. +// Returns [][]SyncDataEntry where outer array is AND, inner arrays are OR. +func (r Rego) SyncData() [][]SyncDataEntry { + return r.annoSyncData +} + +// SyncDataJSON returns the syncData as a pretty-printed JSON string +// suitable for the metadata.gatekeeper.sh/requires-sync-data annotation. +// Output matches the official Gatekeeper library format: double-nested array with outer quotes. +func (r Rego) SyncDataJSON() (string, error) { + if len(r.annoSyncData) == 0 { + return "", nil + } + b, err := json.MarshalIndent(r.annoSyncData, "", " ") + if err != nil { + return "", fmt.Errorf("marshal syncData: %w", err) + } + return "\"" + string(b) + "\"", nil +} + +// Constraints returns the constraint configurations for documenting multiple +// constraint variations from a single policy template. +func (r Rego) Constraints() []ConstraintConfig { + return r.annoConstraints +} + func (r Rego) GetAnnotation(name string) (any, error) { if r.annotations == nil { - return nil, fmt.Errorf("no annotations set") + return nil, errors.New("no annotations set") } switch name { case "title": @@ -196,6 +294,16 @@ func (r Rego) GetAnnotation(name string) (any, error) { } } +// GetAnnotationOrDefault returns the annotation value for the given name, +// or the provided default value if the annotation doesn't exist. +func (r Rego) GetAnnotationOrDefault(name string, defaultValue any) any { + val, err := r.GetAnnotation(name) + if err != nil { + return defaultValue + } + return val +} + func (r *Rego) parseAnnotations(annotations *ast.Annotations) error { if annotations == nil { return nil @@ -250,9 +358,9 @@ func (r *Rego) parseAnnotations(annotations *ast.Annotations) error { metaAnnotations, ok := annotations.Custom[annoAnnotations] if ok { - a, ok := metaAnnotations.(map[string]interface{}) + a, ok := metaAnnotations.(map[string]any) if !ok { - return fmt.Errorf("supplied annotations value is not a map[string]interface{}: %T", metaAnnotations) + return fmt.Errorf("supplied annotations value is not a map[string]any: %T", metaAnnotations) } if r.metaData == nil { r.metaData = &MetaData{} @@ -266,9 +374,9 @@ func (r *Rego) parseAnnotations(annotations *ast.Annotations) error { metaLabels, ok := annotations.Custom[annoLabels] if ok { - l, ok := metaLabels.(map[string]interface{}) + l, ok := metaLabels.(map[string]any) if !ok { - return fmt.Errorf("supplied labels value is not a map[string]interface{}: %T", metaLabels) + return fmt.Errorf("supplied labels value is not a map[string]any: %T", metaLabels) } if r.metaData == nil { r.metaData = &MetaData{} @@ -280,10 +388,62 @@ func (r *Rego) parseAnnotations(annotations *ast.Annotations) error { r.metaData.Labels = labels } + links, ok := annotations.Custom[annoLinks] + if ok { + switch v := links.(type) { + case string: + if v != "" { + r.annoLinks = []string{v} + } + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + r.annoLinks = append(r.annoLinks, s) + } + } + default: + return fmt.Errorf("supplied links value is not a string or array: %T", links) + } + } + + syncData, ok := annotations.Custom[annoSyncData] + if ok && syncData != nil && syncData != "" { + if arr, isArr := syncData.([]any); isArr && len(arr) > 0 { + // detect nesting level: + // - flat list of objects [{...}, {...}] -> wrap as [[{...}, {...}]] (OR only) + // - nested list [[{...}], [{...}]] -> use as-is (AND + OR) + if _, isNested := arr[0].([]any); isNested { + // nested format: [[{...}], [{...}]] + sd, err := remarshal[[][]SyncDataEntry](syncData) + if err != nil { + return fmt.Errorf("unmarshal nested syncData: %w", err) + } + r.annoSyncData = sd + } else { + // flat format: [{...}, {...}] - wrap in outer array + sd, err := remarshal[[]SyncDataEntry](syncData) + if err != nil { + return fmt.Errorf("unmarshal flat syncData: %w", err) + } + r.annoSyncData = [][]SyncDataEntry{sd} + } + } + // empty array or other types are silently ignored + } + + constraints, ok := annotations.Custom[annoConstraints] + if ok { + c, err := remarshal[[]ConstraintConfig](constraints) + if err != nil { + return fmt.Errorf("unmarshal constraints: %w", err) + } + r.annoConstraints = c + } + return nil } -func switchToMap(in map[string]interface{}) (map[string]string, error) { +func switchToMap(in map[string]any) (map[string]string, error) { out := map[string]string{} for k, v := range in { switch c := v.(type) { @@ -449,6 +609,35 @@ func (r Rego) Source() string { return removeComments(r.sanitizedRaw) } +var v0Imports = []string{ + "import future.keywords.contains", + "import future.keywords.every", + "import future.keywords.if", + "import future.keywords.in", + "import future.keywords", + "import rego.v1", +} + +// StripV1Imports removes v0 compatibility imports from Rego source code +// since these are not needed in OPA v1. +func StripV1Imports(source string) string { + var lines []string + prevBlank := false + for line := range strings.SplitSeq(source, "\n") { + trimmed := strings.TrimSpace(line) + if slices.Contains(v0Imports, trimmed) { + continue + } + isBlank := trimmed == "" + if isBlank && prevBlank { + continue + } + prevBlank = isBlank + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + // FullSource returns the original source code inside // of the rego file including comments except the header func (r Rego) FullSource() string { @@ -490,21 +679,24 @@ func (r Rego) SkipConstraint() bool { return r.skipConstraint } -func parseDirectory(directory string, parseImports bool) ([]Rego, error) { - // Recursively find all rego files (ignoring test files), starting at the given directory. - result, err := loader.NewFileLoader(). - WithProcessAnnotation(true). - Filtered([]string{directory}, func(_ string, info os.FileInfo, _ int) bool { - if strings.HasSuffix(info.Name(), "_test.rego") { - return true - } +func parseDirectory(directory string, parseImports bool, regoVersion Version) ([]Rego, error) { + fileLoader := loader.NewFileLoader().WithProcessAnnotation(true) - if !info.IsDir() && filepath.Ext(info.Name()) != ".rego" { - return true - } + if regoVersion == V1 { + fileLoader = fileLoader.WithRegoVersion(ast.RegoV1) + } - return false - }) + result, err := fileLoader.Filtered([]string{directory}, func(_ string, info os.FileInfo, _ int) bool { + if strings.HasSuffix(info.Name(), "_test.rego") { + return true + } + + if !info.IsDir() && filepath.Ext(info.Name()) != ".rego" { + return true + } + + return false + }) if err != nil { return nil, fmt.Errorf("filter rego files: %w", err) } @@ -557,7 +749,6 @@ func parseDirectory(directory string, parseImports bool) ([]Rego, error) { if len(paramsDiff) > 0 { return nil, fmt.Errorf("missing definitions for parameters %v found in the policy `%s`", paramsDiff, file.Name) } - } rego := Rego{ id: getPolicyID(file.Parsed.Rules), @@ -567,6 +758,7 @@ func parseDirectory(directory string, parseImports bool) ([]Rego, error) { raw: string(file.Raw), sanitizedRaw: sanitizeRawSource(file.Raw), annotations: annotations, + regoVersion: regoVersion, } if annotations != nil { @@ -625,8 +817,7 @@ func getHeaderParams(annotations *ast.Annotations) []Parameter { func trimEachLine(raw string) string { var result string - lines := strings.Split(raw, "\n") - for _, line := range lines { + for line := range strings.SplitSeq(raw, "\n") { result += strings.TrimRight(line, "\t ") + "\n" } @@ -635,8 +826,7 @@ func trimEachLine(raw string) string { func removeComments(raw string) string { var regoWithoutComments string - lines := strings.Split(raw, "\n") - for _, line := range lines { + for line := range strings.SplitSeq(raw, "\n") { if strings.HasPrefix(line, "#") { continue } diff --git a/internal/rego/rego_test.go b/internal/rego/rego_test.go index b12e2973..5ac54a6d 100644 --- a/internal/rego/rego_test.go +++ b/internal/rego/rego_test.go @@ -7,6 +7,13 @@ import ( "github.com/open-policy-agent/opa/ast" ) +const minimalTestPolicy = ` +# METADATA +# title: Test Policy +package foo +foo = "bar" { true } +` + func TestKind(t *testing.T) { policy := Rego{ path: "some/path/my-policy/src.rego", @@ -172,6 +179,140 @@ func TestGetPolicyID_Null(t *testing.T) { } } +func TestParseVersion(t *testing.T) { + testCases := []struct { + input string + expected Version + wantErr bool + }{ + {"v0", V0, false}, + {"v1", V1, false}, + {"V0", V0, false}, + {"V1", V1, false}, + {"invalid", V0, true}, + {"", V0, true}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + actual, err := ParseVersion(tc.input) + if tc.wantErr && err == nil { + t.Errorf("expected error for input %q", tc.input) + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error for input %q: %v", tc.input, err) + } + if actual != tc.expected { + t.Errorf("unexpected Version. expected %v, actual %v", tc.expected, actual) + } + }) + } +} + +func TestStripV1Imports(t *testing.T) { + testCases := []struct { + desc string + input string + expected string + }{ + { + desc: "strip future.keywords.if", + input: `package test +import future.keywords.if +violation if { true }`, + expected: `package test +violation if { true }`, + }, + { + desc: "strip future.keywords.contains", + input: `package test +import future.keywords.contains +violation contains msg if { msg := "x" }`, + expected: `package test +violation contains msg if { msg := "x" }`, + }, + { + desc: "strip future.keywords (all)", + input: `package test +import future.keywords +violation contains msg if { msg := "x" }`, + expected: `package test +violation contains msg if { msg := "x" }`, + }, + { + desc: "strip future.keywords.in", + input: `package test +import future.keywords.in +violation if { "a" in ["a", "b"] }`, + expected: `package test +violation if { "a" in ["a", "b"] }`, + }, + { + desc: "strip future.keywords.every", + input: `package test +import future.keywords.every +violation if { every x in [1, 2] { x > 0 } }`, + expected: `package test +violation if { every x in [1, 2] { x > 0 } }`, + }, + { + desc: "strip rego.v1", + input: `package test +import rego.v1 +violation if { true }`, + expected: `package test +violation if { true }`, + }, + { + desc: "preserve other imports", + input: `package test +import future.keywords.if +import data.lib.core +violation if { core.something }`, + expected: `package test +import data.lib.core +violation if { core.something }`, + }, + { + desc: "no future imports", + input: `package test +import data.lib.core +violation if { true }`, + expected: `package test +import data.lib.core +violation if { true }`, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + actual := StripV1Imports(tc.input) + if actual != tc.expected { + t.Errorf("unexpected result.\nexpected:\n%v\n\nactual:\n%v", tc.expected, actual) + } + }) + } +} + +func TestGetViolationsV1(t *testing.T) { + violations, err := GetViolations("../../test/policies", V1) + if err != nil { + t.Fatalf("Error getting v1 violations: %v", err) + } + + if len(violations) != 3 { + t.Fatalf("Expected 3 violations, got %d", len(violations)) + } + + if violations[0].Title() != "The title" { + t.Errorf("unexpected Title. expected %q, actual %q", "The title", violations[0].Title()) + } + + if violations[0].Version() != V1 { + t.Errorf("unexpected Version. expected %v, actual %v", V1, violations[0].Version()) + } +} + func TestGetRuleParamNamesFromInput(t *testing.T) { testCases := []struct { desc string @@ -220,3 +361,455 @@ func TestGetRuleParamNamesFromInput(t *testing.T) { }) } } + +func TestGetAnnotationOrDefault(t *testing.T) { + comments := ` +# METADATA +# title: The Title +# description: The description +# custom: +# rationale: The rationale +package foo +foo = "bar" { true } +` + rule, err := ast.ParseModuleWithOpts("", comments, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + testCases := []struct { + desc string + name string + defaultValue any + expected any + }{ + { + desc: "existing title annotation", + name: "title", + defaultValue: "default title", + expected: "The Title", + }, + { + desc: "existing description annotation", + name: "description", + defaultValue: "default description", + expected: "The description", + }, + { + desc: "existing custom annotation", + name: "rationale", + defaultValue: "default rationale", + expected: "The rationale", + }, + { + desc: "missing annotation returns default", + name: "nonexistent", + defaultValue: "my default", + expected: "my default", + }, + { + desc: "missing annotation with nil default", + name: "nonexistent", + defaultValue: nil, + expected: nil, + }, + { + desc: "missing annotation with empty string default", + name: "nonexistent", + defaultValue: "", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + actual := rego.GetAnnotationOrDefault(tc.name, tc.defaultValue) + if actual != tc.expected { + t.Errorf("unexpected result. expected %v, actual %v", tc.expected, actual) + } + }) + } +} + +func TestGetAnnotationOrDefault_NilAnnotations(t *testing.T) { + rego := Rego{} + + actual := rego.GetAnnotationOrDefault("title", "default value") + if actual != "default value" { + t.Errorf("expected default value when annotations is nil, got %v", actual) + } +} + +func TestLinks_Array(t *testing.T) { + comments := ` +# METADATA +# title: Test Policy +# custom: +# links: +# - https://example.com/link1 +# - https://example.com/link2 +# - https://example.com/link3 +package foo +foo = "bar" { true } +` + rule, err := ast.ParseModuleWithOpts("", comments, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + links := rego.Links() + expected := []string{ + "https://example.com/link1", + "https://example.com/link2", + "https://example.com/link3", + } + + if len(links) != len(expected) { + t.Fatalf("expected %d links, got %d", len(expected), len(links)) + } + + for i, link := range links { + if link != expected[i] { + t.Errorf("link[%d]: expected %q, got %q", i, expected[i], link) + } + } +} + +func TestLinks_SingleString(t *testing.T) { + comments := ` +# METADATA +# title: Test Policy +# custom: +# links: https://example.com/single-link +package foo +foo = "bar" { true } +` + rule, err := ast.ParseModuleWithOpts("", comments, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + links := rego.Links() + if len(links) != 1 { + t.Fatalf("expected 1 link, got %d", len(links)) + } + + if links[0] != "https://example.com/single-link" { + t.Errorf("expected %q, got %q", "https://example.com/single-link", links[0]) + } +} + +func TestLinks_NoLinks(t *testing.T) { + rule, err := ast.ParseModuleWithOpts("", minimalTestPolicy, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + links := rego.Links() + if len(links) != 0 { + t.Errorf("expected no links, got %d", len(links)) + } +} + +func TestSyncData_Flat(t *testing.T) { + comments := ` +# METADATA +# title: Test Policy +# custom: +# syncData: +# - groups: +# - policy +# versions: +# - v1 +# kinds: +# - PodDisruptionBudget +# - groups: +# - apps +# versions: +# - v1 +# kinds: +# - Deployment +# - StatefulSet +package foo +foo = "bar" { true } +` + rule, err := ast.ParseModuleWithOpts("", comments, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + syncData := rego.SyncData() + if len(syncData) != 1 { + t.Fatalf("expected 1 AND group (flat format), got %d", len(syncData)) + } + if len(syncData[0]) != 2 { + t.Fatalf("expected 2 OR entries in first group, got %d", len(syncData[0])) + } + + if syncData[0][0].Groups[0] != "policy" { + t.Errorf("expected groups[0] to be 'policy', got %q", syncData[0][0].Groups[0]) + } + if syncData[0][0].Versions[0] != "v1" { + t.Errorf("expected versions[0] to be 'v1', got %q", syncData[0][0].Versions[0]) + } + if syncData[0][0].Kinds[0] != "PodDisruptionBudget" { + t.Errorf("expected kinds[0] to be 'PodDisruptionBudget', got %q", syncData[0][0].Kinds[0]) + } + + if syncData[0][1].Groups[0] != "apps" { + t.Errorf("expected groups[0] to be 'apps', got %q", syncData[0][1].Groups[0]) + } + if len(syncData[0][1].Kinds) != 2 { + t.Errorf("expected 2 kinds in second entry, got %d", len(syncData[0][1].Kinds)) + } +} + +func TestSyncData_Nested(t *testing.T) { + comments := ` +# METADATA +# title: Test Policy +# custom: +# syncData: +# - - groups: +# - extensions +# versions: +# - v1beta1 +# kinds: +# - Ingress +# - groups: +# - networking.k8s.io +# versions: +# - v1beta1 +# - v1 +# kinds: +# - Ingress +# - - groups: +# - storage.k8s.io +# versions: +# - v1 +# kinds: +# - StorageClass +package foo +foo = "bar" { true } +` + rule, err := ast.ParseModuleWithOpts("", comments, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + syncData := rego.SyncData() + if len(syncData) != 2 { + t.Fatalf("expected 2 AND groups, got %d", len(syncData)) + } + + if len(syncData[0]) != 2 { + t.Fatalf("expected 2 OR entries in first AND group, got %d", len(syncData[0])) + } + if syncData[0][0].Groups[0] != "extensions" { + t.Errorf("expected first OR entry groups[0] to be 'extensions', got %q", syncData[0][0].Groups[0]) + } + if syncData[0][1].Groups[0] != "networking.k8s.io" { + t.Errorf("expected second OR entry groups[0] to be 'networking.k8s.io', got %q", syncData[0][1].Groups[0]) + } + + if len(syncData[1]) != 1 { + t.Fatalf("expected 1 OR entry in second AND group, got %d", len(syncData[1])) + } + if syncData[1][0].Groups[0] != "storage.k8s.io" { + t.Errorf("expected groups[0] to be 'storage.k8s.io', got %q", syncData[1][0].Groups[0]) + } +} + +func TestSyncDataJSON(t *testing.T) { + comments := ` +# METADATA +# title: Test Policy +# custom: +# syncData: +# - groups: +# - policy +# versions: +# - v1 +# kinds: +# - PodDisruptionBudget +package foo +foo = "bar" { true } +` + rule, err := ast.ParseModuleWithOpts("", comments, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + jsonStr, err := rego.SyncDataJSON() + if err != nil { + t.Fatalf("Error getting SyncDataJSON: %s", err) + } + + expected := "\"[\n [\n {\n \"groups\": [\n \"policy\"\n ],\n \"versions\": [\n \"v1\"\n ],\n \"kinds\": [\n \"PodDisruptionBudget\"\n ]\n }\n ]\n]\"" + if jsonStr != expected { + t.Errorf("unexpected JSON output.\nexpected:\n%s\n\nactual:\n%s", expected, jsonStr) + } +} + +func TestSyncData_NoSyncData(t *testing.T) { + rule, err := ast.ParseModuleWithOpts("", minimalTestPolicy, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + syncData := rego.SyncData() + if len(syncData) != 0 { + t.Errorf("expected no syncData, got %d", len(syncData)) + } + + jsonStr, err := rego.SyncDataJSON() + if err != nil { + t.Fatalf("Error getting SyncDataJSON: %s", err) + } + if jsonStr != "" { + t.Errorf("expected empty string for SyncDataJSON, got %q", jsonStr) + } +} + +func TestConstraints(t *testing.T) { + comments := ` +# METADATA +# title: Test Policy +# custom: +# constraints: +# - name: prod-deployments +# description: Strict limits for production +# enforcement: deny +# kinds: +# - apiGroups: +# - apps +# kinds: +# - Deployment +# namespaces: +# - production +# parameters: +# maxReplicas: 10 +# - name: dev-configmaps +# enforcement: warn +# kinds: +# - apiGroups: +# - "" +# kinds: +# - ConfigMap +# excludedNamespaces: +# - kube-system +# parameters: +# maxSize: 1048576 +package foo +foo = "bar" { true } +` + rule, err := ast.ParseModuleWithOpts("", comments, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + constraints := rego.Constraints() + if len(constraints) != 2 { + t.Fatalf("expected 2 constraints, got %d", len(constraints)) + } + + c1 := constraints[0] + if c1.Name != "prod-deployments" { + t.Errorf("expected name 'prod-deployments', got %q", c1.Name) + } + if c1.Description != "Strict limits for production" { + t.Errorf("expected description, got %q", c1.Description) + } + if c1.Enforcement != "deny" { + t.Errorf("expected enforcement 'deny', got %q", c1.Enforcement) + } + if len(c1.Kinds) != 1 || c1.Kinds[0].APIGroups[0] != "apps" { + t.Errorf("expected kinds with apiGroup 'apps', got %v", c1.Kinds) + } + if len(c1.Namespaces) != 1 || c1.Namespaces[0] != "production" { + t.Errorf("expected namespaces ['production'], got %v", c1.Namespaces) + } + + c2 := constraints[1] + if c2.Name != "dev-configmaps" { + t.Errorf("expected name 'dev-configmaps', got %q", c2.Name) + } + if c2.Enforcement != "warn" { + t.Errorf("expected enforcement 'warn', got %q", c2.Enforcement) + } + if len(c2.ExcludedNamespaces) != 1 || c2.ExcludedNamespaces[0] != "kube-system" { + t.Errorf("expected excludedNamespaces ['kube-system'], got %v", c2.ExcludedNamespaces) + } +} + +func TestConstraints_NoConstraints(t *testing.T) { + rule, err := ast.ParseModuleWithOpts("", minimalTestPolicy, ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Error parsing module: %s", err) + } + + rego := Rego{annotations: rule.Annotations[0]} + err = rego.parseAnnotations(rule.Annotations[0]) + if err != nil { + t.Fatalf("Error parsing annotations: %s", err) + } + + constraints := rego.Constraints() + if len(constraints) != 0 { + t.Errorf("expected no constraints, got %d", len(constraints)) + } +} diff --git a/test/output/custom/template_FullMetadata_v1.yaml b/test/output/custom/template_FullMetadata_v1.yaml new file mode 100644 index 00000000..00703150 --- /dev/null +++ b/test/output/custom/template_FullMetadata_v1.yaml @@ -0,0 +1,41 @@ +# This is a custom template for a constraint template +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: fullmetadata +spec: + crd: + spec: + names: + kind: FullMetadata + validation: + openAPIV3Schema: + properties: + super: + description: |- + super duper cool parameter with a description + on two lines. + type: string + targets: + - code: + - engine: Rego + source: + libs: + - |- + package lib.libraryA + + import data.lib.libraryB + - |- + package lib.libraryB + rego: |- + package test_fullmetadata + + import data.lib.libraryA + + policyID := "P123456" + + violation if { + true # some comment + } + version: v1 + target: admission.k8s.gatekeeper.sh