From 6e535b8d2ff25d731d95875d47b7f2d2c2a62c96 Mon Sep 17 00:00:00 2001 From: Matt Burdan Date: Sat, 27 Dec 2025 09:27:09 -0800 Subject: [PATCH 01/10] feat: Add OPA Rego v1 support --- .github/workflows/pull_request.yaml | 27 +++- README.md | 2 +- internal/commands/create.go | 79 ++++++++-- internal/commands/create_test.go | 75 +++++++++- internal/commands/document.go | 23 ++- internal/rego/rego.go | 132 +++++++++++++---- internal/rego/rego_test.go | 137 ++++++++++++++++++ test/policies-v1/full-metadata-v1/src.rego | 45 ++++++ test/policies-v1/lib/libraryA.rego | 1 + test/policies-v1/lib/libraryB.rego | 1 + test/policies-v1/no-metadata-v1/src.rego | 7 + test/policies-v1/partial-metadata-v1/src.rego | 16 ++ 12 files changed, 486 insertions(+), 59 deletions(-) create mode 100644 test/policies-v1/full-metadata-v1/src.rego create mode 100644 test/policies-v1/lib/libraryA.rego create mode 100644 test/policies-v1/lib/libraryB.rego create mode 100644 test/policies-v1/no-metadata-v1/src.rego create mode 100644 test/policies-v1/partial-metadata-v1/src.rego diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index c95e68eb..92da4314 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" test/policies-v1 + - 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/internal/commands/create.go b/internal/commands/create.go index f25940a3..a762d6e1 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,17 @@ 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 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 +97,17 @@ 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)") 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) } @@ -200,7 +213,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 @@ -225,7 +237,6 @@ func renderConstraint(violation rego.Rego, constraintCustomTemplateFile string, } } return constraintBytes, nil - } func renderTemplate(violation rego.Rego, appliedTemplate []byte) ([]byte, error) { @@ -262,13 +273,34 @@ 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 { + source := map[string]any{ + "version": "v1", + "rego": violation.SourceV1(), + } + if len(violation.Dependencies()) > 0 { + var libs []string + for _, lib := range violation.Dependencies() { + libs = append(libs, rego.StripV1Imports(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{ @@ -301,13 +333,34 @@ 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 { + source := map[string]any{ + "version": "v1", + "rego": violation.SourceV1(), + } + if len(violation.Dependencies()) > 0 { + var libs []string + for _, lib := range violation.Dependencies() { + libs = append(libs, rego.StripV1Imports(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 +436,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..bdf2efe0 100644 --- a/internal/commands/create_test.go +++ b/internal/commands/create_test.go @@ -129,9 +129,82 @@ 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-v1/", 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", "", 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", "", 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 should not contain 'import future.keywords'") + } +} diff --git a/internal/commands/document.go b/internal/commands/document.go index 2116bbe4..c87063d3 100644 --- a/internal/commands/document.go +++ b/internal/commands/document.go @@ -99,6 +99,10 @@ 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) + } + path := "." if len(args) > 0 { path = args[0] @@ -113,6 +117,7 @@ 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)") return &cmd } @@ -125,7 +130,12 @@ 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) + } + + docs, err := getDocumentation(path, outputDirectory, regoVersion) if err != nil { return fmt.Errorf("get documentation: %w", err) } @@ -162,8 +172,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) (map[rego.Severity][]Document, error) { + policies, err := rego.GetAllSeveritiesWithoutImports(path, regoVersion) if err != nil { return nil, fmt.Errorf("get all severities: %w", err) } @@ -298,11 +308,12 @@ func getDocumentation(path string, outputDirectory string) (map[rego.Severity][] 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..cf325f1a 100644 --- a/internal/rego/rego.go +++ b/internal/rego/rego.go @@ -3,6 +3,7 @@ package rego import ( "bytes" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -18,6 +19,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 @@ -64,6 +97,7 @@ type Rego struct { skipTemplate bool skipConstraint bool metaData *MetaData + regoVersion Version // Duplicate data from OPA Metadata annotations. annotations *ast.Annotations annoTitle string @@ -75,6 +109,11 @@ type Rego struct { annoLabelSelector *metav1.LabelSelector } +// Version returns the Rego language version of this policy. +func (r Rego) Version() Version { + return r.regoVersion +} + type AnnoKindMatcher struct { APIGroups []string `json:"apiGroups,omitempty"` Kinds []string `json:"kinds,omitempty"` @@ -104,19 +143,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 +175,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) } @@ -181,7 +220,7 @@ func (r Rego) AnnotationParameters() map[string]apiextensionsv1.JSONSchemaProps 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": @@ -250,9 +289,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 +305,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{} @@ -283,7 +322,7 @@ func (r *Rego) parseAnnotations(annotations *ast.Annotations) error { 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 +488,34 @@ func (r Rego) Source() string { return removeComments(r.sanitizedRaw) } +// SourceV1 returns the source code formatted for OPA v1. +// It strips `import future.keywords` and `import rego.v1` imports +// since these are not needed in v1. +func (r Rego) SourceV1() string { + return StripV1Imports(r.Source()) +} + +// StripV1Imports removes `import future.keywords.*` and `import rego.v1` +// 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 strings.HasPrefix(trimmed, "import future.keywords") || + strings.HasPrefix(trimmed, "import rego.v1") { + 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 +557,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 +627,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 +636,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 +695,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 +704,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..1898074d 100644 --- a/internal/rego/rego_test.go +++ b/internal/rego/rego_test.go @@ -172,6 +172,143 @@ 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 TestSourceV1(t *testing.T) { + raw := `package test + +import future.keywords.if +import future.keywords.contains + +violation contains msg if { + msg := "test" +} +` + rego := Rego{ + sanitizedRaw: raw, + } + + actual := rego.SourceV1() + + expected := `package test + +violation contains msg if { + msg := "test" +}` + + if actual != expected { + t.Errorf("unexpected SourceV1.\nexpected:\n%v\n\nactual:\n%v", 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: "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/full-metadata-v1", V1) + if err != nil { + t.Fatalf("Error getting v1 violations: %v", err) + } + + if len(violations) != 1 { + t.Fatalf("Expected 1 violation, got %d", len(violations)) + } + + if violations[0].Title() != "The title v1" { + t.Errorf("unexpected Title. expected %q, actual %q", "The title v1", 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 diff --git a/test/policies-v1/full-metadata-v1/src.rego b/test/policies-v1/full-metadata-v1/src.rego new file mode 100644 index 00000000..92554716 --- /dev/null +++ b/test/policies-v1/full-metadata-v1/src.rego @@ -0,0 +1,45 @@ +# METADATA +# title: The title v1 +# description: The description for v1 policy +# custom: +# parameters: +# super: +# type: string +# description: |- +# super duper cool parameter with a description +# on two lines. +# matchers: +# excludedNamespaces: +# - kube-system +# - gatekeeper-system +# kinds: +# - apiGroups: +# - "" +# kinds: +# - Pod +# - apiGroups: +# - apps +# kinds: +# - DaemonSet +# - Deployment +# - StatefulSet +# labelSelector: +# matchExpressions: +# - key: foo +# operator: In +# values: +# - bar +# - baz +# - key: doggos +# operator: Exists +# namespaces: +# - dev +# - stage +# - prod +package test_fullmetadata_v1 + +policyID := "P654321" + +violation contains {"msg": msg} if { + msg := "violation message" +} diff --git a/test/policies-v1/lib/libraryA.rego b/test/policies-v1/lib/libraryA.rego new file mode 100644 index 00000000..6d13ddca --- /dev/null +++ b/test/policies-v1/lib/libraryA.rego @@ -0,0 +1 @@ +package lib.libraryA diff --git a/test/policies-v1/lib/libraryB.rego b/test/policies-v1/lib/libraryB.rego new file mode 100644 index 00000000..8f16cb0a --- /dev/null +++ b/test/policies-v1/lib/libraryB.rego @@ -0,0 +1 @@ +package lib.libraryB diff --git a/test/policies-v1/no-metadata-v1/src.rego b/test/policies-v1/no-metadata-v1/src.rego new file mode 100644 index 00000000..30b1e5ee --- /dev/null +++ b/test/policies-v1/no-metadata-v1/src.rego @@ -0,0 +1,7 @@ +package test_nometadata_v1 + +policyID := "P123456" + +violation contains {"msg": msg} if { + msg := "some message" +} diff --git a/test/policies-v1/partial-metadata-v1/src.rego b/test/policies-v1/partial-metadata-v1/src.rego new file mode 100644 index 00000000..e46bf792 --- /dev/null +++ b/test/policies-v1/partial-metadata-v1/src.rego @@ -0,0 +1,16 @@ +# METADATA +# title: The title +# description: The description +# custom: +# matchers: +# namespaces: +# - dev +# - stage +# - prod +package test_partialmetadata_v1 + +policyID := "P123456" + +violation contains {"msg": msg} if { + msg := "some message" +} From 667673074c4138d6cf1b1c8aa76b257f12045902 Mon Sep 17 00:00:00 2001 From: konstraint-bot Date: Sat, 27 Dec 2025 17:41:10 +0000 Subject: [PATCH 02/10] generate updated cli docs --- docs/cli/konstraint_create.md | 1 + docs/cli/konstraint_doc.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/cli/konstraint_create.md b/docs/cli/konstraint_create.md index 0c686572..ec2908bb 100644 --- a/docs/cli/konstraint_create.md +++ b/docs/cli/konstraint_create.md @@ -30,6 +30,7 @@ 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 ``` diff --git a/docs/cli/konstraint_doc.md b/docs/cli/konstraint_doc.md index b059759d..c97f6504 100644 --- a/docs/cli/konstraint_doc.md +++ b/docs/cli/konstraint_doc.md @@ -26,6 +26,7 @@ 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") --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) ``` From 611726882d9dac80542e03cbaffd9880a95b3fe5 Mon Sep 17 00:00:00 2001 From: Matt Burdan Date: Sat, 27 Dec 2025 10:34:12 -0800 Subject: [PATCH 03/10] fix: use existing test policies for v0 and v1 testing --- .github/workflows/pull_request.yaml | 2 +- internal/commands/create_test.go | 2 +- internal/rego/rego_test.go | 10 ++--- test/policies-v1/full-metadata-v1/src.rego | 45 ------------------- test/policies-v1/lib/libraryA.rego | 1 - test/policies-v1/lib/libraryB.rego | 1 - test/policies-v1/no-metadata-v1/src.rego | 7 --- test/policies-v1/partial-metadata-v1/src.rego | 16 ------- 8 files changed, 7 insertions(+), 77 deletions(-) delete mode 100644 test/policies-v1/full-metadata-v1/src.rego delete mode 100644 test/policies-v1/lib/libraryA.rego delete mode 100644 test/policies-v1/lib/libraryB.rego delete mode 100644 test/policies-v1/no-metadata-v1/src.rego delete mode 100644 test/policies-v1/partial-metadata-v1/src.rego diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 92da4314..c1da309a 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -134,7 +134,7 @@ jobs: run: opa check --v0-compatible --strict --ignore "*.yaml" examples - name: opa check strict (v1) - run: opa check --strict --ignore "*.yaml" test/policies-v1 + run: opa check --strict --ignore "*.yaml" examples - name: setup regal uses: styrainc/setup-regal@v1.0.0 diff --git a/internal/commands/create_test.go b/internal/commands/create_test.go index bdf2efe0..2196d0c9 100644 --- a/internal/commands/create_test.go +++ b/internal/commands/create_test.go @@ -137,7 +137,7 @@ func GetViolations() ([]rego.Rego, error) { } func GetViolationsV1() ([]rego.Rego, error) { - violations, err := rego.GetViolations("../../test/policies-v1/", rego.V1) + violations, err := rego.GetViolations("../../test/policies/", rego.V1) if err != nil { return nil, err } diff --git a/internal/rego/rego_test.go b/internal/rego/rego_test.go index 1898074d..b1876a68 100644 --- a/internal/rego/rego_test.go +++ b/internal/rego/rego_test.go @@ -291,17 +291,17 @@ violation if { true }`, } func TestGetViolationsV1(t *testing.T) { - violations, err := GetViolations("../../test/policies-v1/full-metadata-v1", V1) + violations, err := GetViolations("../../test/policies", V1) if err != nil { t.Fatalf("Error getting v1 violations: %v", err) } - if len(violations) != 1 { - t.Fatalf("Expected 1 violation, got %d", len(violations)) + if len(violations) != 3 { + t.Fatalf("Expected 3 violations, got %d", len(violations)) } - if violations[0].Title() != "The title v1" { - t.Errorf("unexpected Title. expected %q, actual %q", "The title v1", violations[0].Title()) + if violations[0].Title() != "The title" { + t.Errorf("unexpected Title. expected %q, actual %q", "The title", violations[0].Title()) } if violations[0].Version() != V1 { diff --git a/test/policies-v1/full-metadata-v1/src.rego b/test/policies-v1/full-metadata-v1/src.rego deleted file mode 100644 index 92554716..00000000 --- a/test/policies-v1/full-metadata-v1/src.rego +++ /dev/null @@ -1,45 +0,0 @@ -# METADATA -# title: The title v1 -# description: The description for v1 policy -# custom: -# parameters: -# super: -# type: string -# description: |- -# super duper cool parameter with a description -# on two lines. -# matchers: -# excludedNamespaces: -# - kube-system -# - gatekeeper-system -# kinds: -# - apiGroups: -# - "" -# kinds: -# - Pod -# - apiGroups: -# - apps -# kinds: -# - DaemonSet -# - Deployment -# - StatefulSet -# labelSelector: -# matchExpressions: -# - key: foo -# operator: In -# values: -# - bar -# - baz -# - key: doggos -# operator: Exists -# namespaces: -# - dev -# - stage -# - prod -package test_fullmetadata_v1 - -policyID := "P654321" - -violation contains {"msg": msg} if { - msg := "violation message" -} diff --git a/test/policies-v1/lib/libraryA.rego b/test/policies-v1/lib/libraryA.rego deleted file mode 100644 index 6d13ddca..00000000 --- a/test/policies-v1/lib/libraryA.rego +++ /dev/null @@ -1 +0,0 @@ -package lib.libraryA diff --git a/test/policies-v1/lib/libraryB.rego b/test/policies-v1/lib/libraryB.rego deleted file mode 100644 index 8f16cb0a..00000000 --- a/test/policies-v1/lib/libraryB.rego +++ /dev/null @@ -1 +0,0 @@ -package lib.libraryB diff --git a/test/policies-v1/no-metadata-v1/src.rego b/test/policies-v1/no-metadata-v1/src.rego deleted file mode 100644 index 30b1e5ee..00000000 --- a/test/policies-v1/no-metadata-v1/src.rego +++ /dev/null @@ -1,7 +0,0 @@ -package test_nometadata_v1 - -policyID := "P123456" - -violation contains {"msg": msg} if { - msg := "some message" -} diff --git a/test/policies-v1/partial-metadata-v1/src.rego b/test/policies-v1/partial-metadata-v1/src.rego deleted file mode 100644 index e46bf792..00000000 --- a/test/policies-v1/partial-metadata-v1/src.rego +++ /dev/null @@ -1,16 +0,0 @@ -# METADATA -# title: The title -# description: The description -# custom: -# matchers: -# namespaces: -# - dev -# - stage -# - prod -package test_partialmetadata_v1 - -policyID := "P123456" - -violation contains {"msg": msg} if { - msg := "some message" -} From 07ba54483bb46904886c24c8c362885c324e8e45 Mon Sep 17 00:00:00 2001 From: Matt Burdan Date: Sat, 27 Dec 2025 11:56:43 -0800 Subject: [PATCH 04/10] fix: add v1 e2e testing to cron workflow --- .github/workflows/cron_e2e.yaml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 From 0db89a2c935ea544cfaad0a13596026b937e72a7 Mon Sep 17 00:00:00 2001 From: Matt Burdan Date: Sat, 27 Dec 2025 12:03:43 -0800 Subject: [PATCH 05/10] fix: strip imports from doc cmd --- internal/commands/document.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/commands/document.go b/internal/commands/document.go index c87063d3..26927b45 100644 --- a/internal/commands/document.go +++ b/internal/commands/document.go @@ -292,19 +292,22 @@ func getDocumentation(path string, outputDirectory string, regoVersion rego.Vers 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 regoVersion == rego.V1 { + regoSource = rego.StripV1Imports(regoSource) } if viper.GetBool("no-rego") { - rego = "" + regoSource = "" } document := Document{ Header: header, URL: url, - Rego: rego, + Rego: regoSource, Policy: policy, } From 4432276697961a273f7dcc4e43abcd75b69507a1 Mon Sep 17 00:00:00 2001 From: Matt Burdan Date: Sat, 27 Dec 2025 12:20:56 -0800 Subject: [PATCH 06/10] fix: add v1 support to custom constraint template --- .../commands/constrainttemplate_template.tpl | 12 ++++++ internal/commands/create.go | 4 +- internal/commands/create_test.go | 30 ++++++++++++++ .../custom/template_FullMetadata_v1.yaml | 41 +++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 test/output/custom/template_FullMetadata_v1.yaml diff --git a/internal/commands/constrainttemplate_template.tpl b/internal/commands/constrainttemplate_template.tpl index 474e4355..4459737c 100644 --- a/internal/commands/constrainttemplate_template.tpl +++ b/internal/commands/constrainttemplate_template.tpl @@ -14,8 +14,20 @@ spec: properties: {{- .AnnotationParameters | toJSON | fromJSON | toIndentYAML 2 | nindent 12 }} {{- end }} targets: + {{- if eq .Version.String "v1" }} + - code: + - engine: Rego + source: + libs: {{- range .Dependencies }} + - |- {{- stripV1Imports . | nindent 10 -}} + {{ end }} + rego: |- {{- .SourceV1 | nindent 10 }} + version: v1 + target: admission.k8s.gatekeeper.sh + {{- else }} - libs: {{- range .Dependencies }} - |- {{- . | nindent 6 -}} {{ end }} rego: |- {{- .Source | nindent 6 }} target: admission.k8s.gatekeeper.sh + {{- end }} diff --git a/internal/commands/create.go b/internal/commands/create.go index a762d6e1..0e17f592 100644 --- a/internal/commands/create.go +++ b/internal/commands/create.go @@ -240,7 +240,9 @@ func renderConstraint(violation rego.Rego, constraintCustomTemplateFile string, } func renderTemplate(violation rego.Rego, appliedTemplate []byte) ([]byte, error) { - t, err := template.New("template").Funcs(sprigin.FuncMap()).Parse(string(appliedTemplate)) + funcMap := sprigin.FuncMap() + funcMap["stripV1Imports"] = rego.StripV1Imports + t, err := template.New("template").Funcs(funcMap).Parse(string(appliedTemplate)) if err != nil { return nil, fmt.Errorf("parsing template: %w", err) } diff --git a/internal/commands/create_test.go b/internal/commands/create_test.go index 2196d0c9..823ad7c6 100644 --- a/internal/commands/create_test.go +++ b/internal/commands/create_test.go @@ -128,6 +128,36 @@ func TestRenderConstraintTemplateWithCustomTemplate(t *testing.T) { } } +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", 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 GetViolations() ([]rego.Rego, error) { violations, err := rego.GetViolations("../../test/policies/", rego.V0) if err != nil { 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 From 442f1f096a73e098e0973f93c9d86297bbfc0908 Mon Sep 17 00:00:00 2001 From: Matt Burdan Date: Sat, 27 Dec 2025 12:34:35 -0800 Subject: [PATCH 07/10] fix: remove dupe --- internal/commands/constrainttemplate_template.tpl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/commands/constrainttemplate_template.tpl b/internal/commands/constrainttemplate_template.tpl index 4459737c..d53ccd12 100644 --- a/internal/commands/constrainttemplate_template.tpl +++ b/internal/commands/constrainttemplate_template.tpl @@ -23,11 +23,10 @@ spec: {{ end }} rego: |- {{- .SourceV1 | nindent 10 }} version: v1 - target: admission.k8s.gatekeeper.sh {{- else }} - libs: {{- range .Dependencies }} - |- {{- . | nindent 6 -}} {{ end }} rego: |- {{- .Source | nindent 6 }} - target: admission.k8s.gatekeeper.sh {{- end }} + target: admission.k8s.gatekeeper.sh From 6b9a1c81b117143c5227c9baa135cafabb59212e Mon Sep 17 00:00:00 2001 From: Matt Burdan Date: Tue, 6 Jan 2026 02:42:54 -0800 Subject: [PATCH 08/10] use explicit import list --- internal/rego/rego.go | 17 +++++++++++++---- internal/rego/rego_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/internal/rego/rego.go b/internal/rego/rego.go index cf325f1a..fd485ef2 100644 --- a/internal/rego/rego.go +++ b/internal/rego/rego.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strings" @@ -495,15 +496,23 @@ func (r Rego) SourceV1() string { return StripV1Imports(r.Source()) } -// StripV1Imports removes `import future.keywords.*` and `import rego.v1` -// imports from Rego source code since these are not needed in OPA v1. +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 strings.HasPrefix(trimmed, "import future.keywords") || - strings.HasPrefix(trimmed, "import rego.v1") { + if slices.Contains(v0Imports, trimmed) { continue } isBlank := trimmed == "" diff --git a/internal/rego/rego_test.go b/internal/rego/rego_test.go index b1876a68..f75ba73f 100644 --- a/internal/rego/rego_test.go +++ b/internal/rego/rego_test.go @@ -258,6 +258,30 @@ 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", From c2472e9810000316751a63e9f163dda6d99f7342 Mon Sep 17 00:00:00 2001 From: Matt Burdan Date: Tue, 6 Jan 2026 02:43:20 -0800 Subject: [PATCH 09/10] Add --strip-v0-imports flag --- .../commands/constrainttemplate_template.tpl | 8 +- internal/commands/create.go | 86 +++++++++++++++---- internal/commands/create_test.go | 54 ++++++++++-- internal/commands/document.go | 18 +++- internal/rego/rego.go | 7 -- internal/rego/rego_test.go | 27 ------ 6 files changed, 136 insertions(+), 64 deletions(-) diff --git a/internal/commands/constrainttemplate_template.tpl b/internal/commands/constrainttemplate_template.tpl index d53ccd12..2b48e91c 100644 --- a/internal/commands/constrainttemplate_template.tpl +++ b/internal/commands/constrainttemplate_template.tpl @@ -18,10 +18,10 @@ spec: - code: - engine: Rego source: - libs: {{- range .Dependencies }} - - |- {{- stripV1Imports . | nindent 10 -}} - {{ end }} - rego: |- {{- .SourceV1 | nindent 10 }} + libs: {{- range .RenderedDependencies }} + - |- {{- . | nindent 10 }} + {{- end }} + rego: |- {{- .RenderedSource | nindent 10 }} version: v1 {{- else }} - libs: {{- range .Dependencies }} diff --git a/internal/commands/create.go b/internal/commands/create.go index 0e17f592..dd7ab852 100644 --- a/internal/commands/create.go +++ b/internal/commands/create.go @@ -70,6 +70,14 @@ Create constraints with the Gatekeeper enforcement action set to dryrun 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 errors.New("need to set either constraint-template-custom-template-file or constraint-template-version") } @@ -98,6 +106,7 @@ Create constraints with the Gatekeeper enforcement action set to dryrun 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 } @@ -144,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) } @@ -182,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 @@ -191,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) } @@ -221,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) } @@ -239,23 +249,49 @@ func renderConstraint(violation rego.Rego, constraintCustomTemplateFile string, return constraintBytes, nil } -func renderTemplate(violation rego.Rego, appliedTemplate []byte) ([]byte, error) { - funcMap := sprigin.FuncMap() - funcMap["stripV1Imports"] = rego.StripV1Imports - t, err := template.New("template").Funcs(funcMap).Parse(string(appliedTemplate)) +type templateData struct { + rego.Rego + stripV0Imports bool +} + +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", @@ -281,14 +317,22 @@ func getConstraintTemplatev1(violation rego.Rego, _ *log.Entry) *v1.ConstraintTe } if violation.Version() == rego.V1 { + regoSource := violation.Source() + if stripV0Imports { + regoSource = rego.StripV1Imports(regoSource) + } source := map[string]any{ "version": "v1", - "rego": violation.SourceV1(), + "rego": regoSource, } if len(violation.Dependencies()) > 0 { var libs []string for _, lib := range violation.Dependencies() { - libs = append(libs, rego.StripV1Imports(lib)) + if stripV0Imports { + libs = append(libs, rego.StripV1Imports(lib)) + } else { + libs = append(libs, lib) + } } source["libs"] = libs } @@ -315,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", @@ -341,14 +385,22 @@ func getConstraintTemplatev1beta1(violation rego.Rego, _ *log.Entry) *v1beta1.Co } if violation.Version() == rego.V1 { + regoSource := violation.Source() + if stripV0Imports { + regoSource = rego.StripV1Imports(regoSource) + } source := map[string]any{ "version": "v1", - "rego": violation.SourceV1(), + "rego": regoSource, } if len(violation.Dependencies()) > 0 { var libs []string for _, lib := range violation.Dependencies() { - libs = append(libs, rego.StripV1Imports(lib)) + if stripV0Imports { + libs = append(libs, rego.StripV1Imports(lib)) + } else { + libs = append(libs, lib) + } } source["libs"] = libs } diff --git a/internal/commands/create_test.go b/internal/commands/create_test.go index 823ad7c6..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,7 @@ 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) @@ -144,7 +144,7 @@ func TestRenderConstraintTemplateWithCustomTemplateV1(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", true, entry.LastEntry()) if err != nil { t.Errorf("Error rendering constrainttemplate: %v", err) @@ -186,7 +186,7 @@ func TestRenderConstraintTemplateV0Format(t *testing.T) { t.Fatal("No violations found") } - actual, err := renderConstraintTemplate(violations[0], "v1", "", entry.LastEntry()) + actual, err := renderConstraintTemplate(violations[0], "v1", "", false, entry.LastEntry()) if err != nil { t.Fatalf("Error rendering constrainttemplate: %v", err) } @@ -217,7 +217,7 @@ func TestRenderConstraintTemplateV1Format(t *testing.T) { t.Fatal("No violations found") } - actual, err := renderConstraintTemplate(violations[0], "v1", "", entry.LastEntry()) + actual, err := renderConstraintTemplate(violations[0], "v1", "", true, entry.LastEntry()) if err != nil { t.Fatalf("Error rendering constrainttemplate: %v", err) } @@ -235,6 +235,48 @@ func TestRenderConstraintTemplateV1Format(t *testing.T) { t.Error("v1 template should contain 'version: v1' in source") } if bytes.Contains(actual, []byte("import future.keywords")) { - t.Error("v1 template should not contain '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 26927b45..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" @@ -103,6 +104,14 @@ Set the URL where the policies are hosted at 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] @@ -118,6 +127,7 @@ Set the URL where the policies are hosted at 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 } @@ -135,7 +145,9 @@ func runDocCommand(path string) error { return fmt.Errorf("parse rego-version flag: %w", err) } - docs, err := getDocumentation(path, outputDirectory, regoVersion) + stripV0Imports := viper.GetBool("strip-v0-imports") + + docs, err := getDocumentation(path, outputDirectory, regoVersion, stripV0Imports) if err != nil { return fmt.Errorf("get documentation: %w", err) } @@ -172,7 +184,7 @@ func runDocCommand(path string) error { return nil } -func getDocumentation(path string, outputDirectory string, regoVersion rego.Version) (map[rego.Severity][]Document, error) { +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) @@ -298,7 +310,7 @@ func getDocumentation(path string, outputDirectory string, regoVersion rego.Vers } else { regoSource = policy.Source() } - if regoVersion == rego.V1 { + if stripV0Imports { regoSource = rego.StripV1Imports(regoSource) } if viper.GetBool("no-rego") { diff --git a/internal/rego/rego.go b/internal/rego/rego.go index fd485ef2..64a53fa7 100644 --- a/internal/rego/rego.go +++ b/internal/rego/rego.go @@ -489,13 +489,6 @@ func (r Rego) Source() string { return removeComments(r.sanitizedRaw) } -// SourceV1 returns the source code formatted for OPA v1. -// It strips `import future.keywords` and `import rego.v1` imports -// since these are not needed in v1. -func (r Rego) SourceV1() string { - return StripV1Imports(r.Source()) -} - var v0Imports = []string{ "import future.keywords.contains", "import future.keywords.every", diff --git a/internal/rego/rego_test.go b/internal/rego/rego_test.go index f75ba73f..2db193e9 100644 --- a/internal/rego/rego_test.go +++ b/internal/rego/rego_test.go @@ -202,33 +202,6 @@ func TestParseVersion(t *testing.T) { } } -func TestSourceV1(t *testing.T) { - raw := `package test - -import future.keywords.if -import future.keywords.contains - -violation contains msg if { - msg := "test" -} -` - rego := Rego{ - sanitizedRaw: raw, - } - - actual := rego.SourceV1() - - expected := `package test - -violation contains msg if { - msg := "test" -}` - - if actual != expected { - t.Errorf("unexpected SourceV1.\nexpected:\n%v\n\nactual:\n%v", expected, actual) - } -} - func TestStripV1Imports(t *testing.T) { testCases := []struct { desc string From 5d0f6cee80e68c2097838d23e59f19e8ea72e4fa Mon Sep 17 00:00:00 2001 From: konstraint-bot Date: Tue, 6 Jan 2026 16:45:39 +0000 Subject: [PATCH 10/10] generate updated cli docs --- docs/cli/konstraint_create.md | 1 + docs/cli/konstraint_doc.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/cli/konstraint_create.md b/docs/cli/konstraint_create.md index ec2908bb..ef0ee36d 100644 --- a/docs/cli/konstraint_create.md +++ b/docs/cli/konstraint_create.md @@ -32,6 +32,7 @@ Create constraints with the Gatekeeper enforcement action set to dryrun --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 c97f6504..160b204d 100644 --- a/docs/cli/konstraint_doc.md +++ b/docs/cli/konstraint_doc.md @@ -27,6 +27,7 @@ Set the URL where the policies are hosted at --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) ```