From 4c9e25123363c0913dc5b11e4819f900a0c26048 Mon Sep 17 00:00:00 2001 From: jdesouza Date: Wed, 4 Dec 2024 14:22:20 -0300 Subject: [PATCH] INSIGHTS-519 Support .util validation during validation runtime (#217) * [WIP] INSIGHTS-520 Lib support to OPA validation * Removed code * Removed code * Removed code * Fixed issue * Adding etsts * Upgraded libs * Revert "Upgraded libs" This reverts commit 29da67b7783992d596e0e3634b450ad1d699f1df. * Upgraded libs * Added tests * Added tests * Added tests * Added tests * Added tests * Merge * Fixed issue * Fixed issue --- pkg/cli/validate_opa.go | 7 +- pkg/opavalidation/opavalidation.go | 67 ++++++++++++++++--- pkg/opavalidation/opavalidation_test.go | 14 +++- pkg/opavalidation/testdata/fileWithLib.rego | 17 +++++ pkg/opavalidation/testdata/libs/invalid.rego | 1 + .../testdata/libs/invalidFairwindsLib.rego | 17 +++++ pkg/opavalidation/testdata/libs/utils.rego | 5 ++ pkg/opavalidation/testdata/pod1.yaml | 12 ++++ pkg/opavalidation/testdata/pod2.yaml | 12 ++++ 9 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 pkg/opavalidation/testdata/fileWithLib.rego create mode 100644 pkg/opavalidation/testdata/libs/invalid.rego create mode 100644 pkg/opavalidation/testdata/libs/invalidFairwindsLib.rego create mode 100644 pkg/opavalidation/testdata/libs/utils.rego create mode 100644 pkg/opavalidation/testdata/pod1.yaml create mode 100644 pkg/opavalidation/testdata/pod2.yaml diff --git a/pkg/cli/validate_opa.go b/pkg/cli/validate_opa.go index 56a938a..e945fe7 100644 --- a/pkg/cli/validate_opa.go +++ b/pkg/cli/validate_opa.go @@ -13,7 +13,7 @@ import ( "github.com/spf13/cobra" ) -var regoFileName, objectFileName, batchDir, objectNamespaceOverride, insightsInfoCluster, insightsInfoContext string +var regoFileName, objectFileName, batchDir, libsDir, objectNamespaceOverride, insightsInfoCluster, insightsInfoContext string var expectActionItem opavalidation.ExpectActionItemOptions // OPACmd represents the validate opa command @@ -34,7 +34,7 @@ var OPACmd = &cobra.Command{ os.Exit(1) } if regoFileName != "" { - _, err := opavalidation.Run(regoFileName, objectFileName, expectActionItem, fwrego.InsightsInfo{InsightsContext: insightsInfoContext, Cluster: insightsInfoCluster}, objectNamespaceOverride) + _, err := opavalidation.Run(regoFileName, objectFileName, expectActionItem, fwrego.InsightsInfo{InsightsContext: insightsInfoContext, Cluster: insightsInfoCluster}, objectNamespaceOverride, libsDir) if err != nil { fmt.Printf("OPA policy failed validation: %v\n", err) os.Exit(1) @@ -43,7 +43,7 @@ var OPACmd = &cobra.Command{ } if batchDir != "" { - _, failedPolicies, err := opavalidation.RunBatch(batchDir, expectActionItem, fwrego.InsightsInfo{InsightsContext: insightsInfoContext, Cluster: insightsInfoCluster}, objectNamespaceOverride) + _, failedPolicies, err := opavalidation.RunBatch(batchDir, expectActionItem, fwrego.InsightsInfo{InsightsContext: insightsInfoContext, Cluster: insightsInfoCluster}, objectNamespaceOverride, libsDir) fmt.Println() // separate output from RunBatch if err != nil { fmt.Printf("OPA policies failed validation: %v\n", err) @@ -88,5 +88,6 @@ func init() { OPACmd.Flags().StringVarP(&objectNamespaceOverride, "object-namespace", "N", "", "A Kubernetes namespace to override any defined in the Kubernetes object being passed as input to an OPA policy.") OPACmd.Flags().StringVarP(&insightsInfoCluster, "insightsinfo-cluster", "l", "test", "A Kubernetes cluster name returned by the Insights-provided insightsinfo() rego function.") OPACmd.Flags().StringVarP(&insightsInfoContext, "insightsinfo-context", "t", "Agent", "An Insights context returned by the Insights-provided insightsinfo() rego function. The context returned by Insights plugins is typically one of: CI/CD, Admission, or Agent.") + OPACmd.Flags().StringVarP(&libsDir, "libs-dir", "L", "", "A directory containing additional rego libraries to load. This option is not required, but can be used to load additional rego libraries.") OPACmd.Flags().BoolVarP(&expectActionItem.Default, "expect-action-item", "i", true, "Whether to expect the OPA policy to output one action item (true) or 0 action items (false). This option is applied to Kubernetes manifest files with no .success.yaml nor .failure.yaml extension.") } diff --git a/pkg/opavalidation/opavalidation.go b/pkg/opavalidation/opavalidation.go index 71c9442..5653694 100644 --- a/pkg/opavalidation/opavalidation.go +++ b/pkg/opavalidation/opavalidation.go @@ -7,6 +7,8 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "sort" "strings" fwrego "github.com/fairwindsops/insights-plugins/plugins/opa/pkg/rego" @@ -24,7 +26,7 @@ const ( // Run is a ValidateRego() wrapper that validates and prints resulting actionItems. This is // meant to be called from a cobra.Command{}. -func Run(regoFileName, objectFileName string, expectAIOptions ExpectActionItemOptions, insightsInfo fwrego.InsightsInfo, objectNamespaceOverride string) (actionItems, error) { +func Run(regoFileName, objectFileName string, expectAIOptions ExpectActionItemOptions, insightsInfo fwrego.InsightsInfo, objectNamespaceOverride, libsDir string) (actionItems, error) { b, err := os.ReadFile(regoFileName) if err != nil { return nil, fmt.Errorf("error reading OPA policy %s: %v", regoFileName, err) @@ -34,9 +36,29 @@ func Run(regoFileName, objectFileName string, expectAIOptions ExpectActionItemOp if err != nil { return nil, fmt.Errorf("error reading Kubernetes manifest %s: %v", objectFileName, err) } + libs := map[string]string{} + if libsDir != "" { + files, err := FindFilesWithExtension(libsDir, ".rego") + if err != nil { + return nil, fmt.Errorf("unable to list .rego files in %s: %v", libsDir, err) + } + for _, lib := range files { + libContent, err := os.ReadFile(lib) + if err != nil { + return nil, fmt.Errorf("error reading OPA library %s: %v", lib, err) + } + if !IsOPACustomLibrary(string(libContent)) { + logrus.Warnf("Skipping non-OPA library %s", lib) + continue + } + libName := strings.TrimSuffix(filepath.Base(lib), filepath.Ext(lib)) + libs[libName] = string(libContent) + } + } + baseRegoFileName := filepath.Base(regoFileName) eventType := strings.TrimSuffix(baseRegoFileName, filepath.Ext(baseRegoFileName)) - actionItems, err := ValidateRego(context.TODO(), regoContent, b, insightsInfo, eventType, objectNamespaceOverride) + actionItems, err := ValidateRego(context.TODO(), regoContent, b, insightsInfo, eventType, objectNamespaceOverride, libs) if err != nil { return actionItems, err } @@ -64,7 +86,7 @@ func Run(regoFileName, objectFileName string, expectAIOptions ExpectActionItemOp // Each OPA policy is validated with a Kubernetes manifest file named of the // form {base rego filename} and the extensions .yaml, .success.yaml, and // .failure.yaml (the last two of which are configurable). -func RunBatch(batchDir string, expectAIOptions ExpectActionItemOptions, insightsInfo fwrego.InsightsInfo, objectNamespaceOverride string) (successfulPolicies, failedPolicies []string, err error) { +func RunBatch(batchDir string, expectAIOptions ExpectActionItemOptions, insightsInfo fwrego.InsightsInfo, objectNamespaceOverride, libsDir string) (successfulPolicies, failedPolicies []string, err error) { regoFiles, err := FindFilesWithExtension(batchDir, ".rego") if err != nil { return successfulPolicies, failedPolicies, fmt.Errorf("unable to list .rego files: %v", err) @@ -78,7 +100,7 @@ func RunBatch(batchDir string, expectAIOptions ExpectActionItemOptions, insights } for _, objectFileName := range objectFileNames { logrus.Infof("Validating OPA policy %s with input %s (expectActionItem=%v)", regoFileName, objectFileName, expectAIOptions.ForFileName(objectFileName)) - _, err := Run(regoFileName, objectFileName, expectAIOptions, insightsInfo, objectNamespaceOverride) + _, err := Run(regoFileName, objectFileName, expectAIOptions, insightsInfo, objectNamespaceOverride, libsDir) if err != nil { logrus.Errorf("Failed validation of OPA policy %s using input %s: %v\n", regoFileName, objectFileName, err) if !lo.Contains(failedPolicies, regoFileName) { @@ -99,7 +121,7 @@ func RunBatch(batchDir string, expectAIOptions ExpectActionItemOptions, insights // ValidateRego validates rego by executing rego with an input object. // Validation includes signatures for Insights-provided rego functions. -func ValidateRego(ctx context.Context, regoAsString string, objectAsBytes []byte, insightsInfo fwrego.InsightsInfo, eventType string, objectNamespaceOverride string) (actionItems, error) { +func ValidateRego(ctx context.Context, regoAsString string, objectAsBytes []byte, insightsInfo fwrego.InsightsInfo, eventType string, objectNamespaceOverride string, libs map[string]string) (actionItems, error) { if !strings.Contains(regoAsString, "package fairwinds") { return nil, errors.New("policy must be within a fairwinds package. The policy must contain the statement: package fairwinds") } @@ -111,7 +133,7 @@ func ValidateRego(ctx context.Context, regoAsString string, objectAsBytes []byte if err != nil { return nil, fmt.Errorf("while overriding object namespace with %q: %v", objectNamespaceOverride, err) } - regoResult, err := runRegoForObject(ctx, regoAsString, objectAsMap, insightsInfo) + regoResult, err := runRegoForObject(ctx, regoAsString, objectAsMap, insightsInfo, libs) if err != nil { return nil, err } @@ -128,8 +150,8 @@ func ValidateRego(ctx context.Context, regoAsString string, objectAsBytes []byte } // runRegoForObject executes rego with a Kubernetes object as input. -func runRegoForObject(ctx context.Context, regoAsString string, object map[string]interface{}, insightsInfo fwrego.InsightsInfo) (rego.ResultSet, error) { - query, err := rego.New(rego.EnablePrintStatements(true), rego.PrintHook(topdown.NewPrintHook(os.Stdout)), +func runRegoForObject(ctx context.Context, regoAsString string, object map[string]interface{}, insightsInfo fwrego.InsightsInfo, libs map[string]string) (rego.ResultSet, error) { + opts := []func(r *rego.Rego){rego.EnablePrintStatements(true), rego.PrintHook(topdown.NewPrintHook(os.Stdout)), rego.Query("results = data"), rego.Module("fairwinds", regoAsString), rego.Function2( @@ -151,14 +173,37 @@ func runRegoForObject(ctx context.Context, regoAsString string, object map[strin Name: "insightsinfo", Decl: types.NewFunction(types.Args(types.S), types.A), }, - fwrego.GetInsightsInfoFunction(&insightsInfo))).PrepareForEval(ctx) + fwrego.GetInsightsInfoFunction(&insightsInfo), + ), + } + var libNames []string + for libName := range libs { + libNames = append(libNames, libName) + } + sort.Strings(libNames) + for _, libName := range libNames { + opts = append(opts, rego.Module(libName, libs[libName])) + } + query, err := rego.New(opts...).PrepareForEval(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("error preparing rego for evaluation: %v", err) } preparedInput := rego.EvalInput(object) rs, err := query.Eval(ctx, preparedInput) if err != nil { - return nil, err + return nil, fmt.Errorf("error evaluating rego: %v", err) } return rs, nil } + +var isCheckRE = regexp.MustCompile(`^package\s+fairwinds\s*(#.*)?$`) + +func IsOPACustomLibrary(rego string) bool { + for _, line := range strings.Split(strings.TrimSuffix(rego, "\n"), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "package") { + isCheck := isCheckRE.MatchString(strings.TrimSpace(line)) + return !isCheck + } + } + return false +} diff --git a/pkg/opavalidation/opavalidation_test.go b/pkg/opavalidation/opavalidation_test.go index 53cf3b2..d4401a5 100644 --- a/pkg/opavalidation/opavalidation_test.go +++ b/pkg/opavalidation/opavalidation_test.go @@ -7,6 +7,7 @@ import ( "github.com/fairwindsops/insights-cli/pkg/opavalidation" fwrego "github.com/fairwindsops/insights-plugins/plugins/opa/pkg/rego" + "github.com/stretchr/testify/assert" ) func TestValidateRego(t *testing.T) { @@ -58,7 +59,7 @@ func TestValidateRego(t *testing.T) { if err != nil { t.Fatalf("error reading %s: %v", tc.objectFileName, err) } - gotActionItems, gotErr := opavalidation.ValidateRego(context.TODO(), regoAsString, objectAsBytes, fwrego.InsightsInfo{}, "TestEvent", "") + gotActionItems, gotErr := opavalidation.ValidateRego(context.TODO(), regoAsString, objectAsBytes, fwrego.InsightsInfo{}, "TestEvent", "", nil) if !tc.expectError && gotErr != nil { t.Fatal(gotErr) } @@ -76,3 +77,14 @@ func TestValidateRego(t *testing.T) { }) } } + +func TestRunWithLibs(t *testing.T) { + ais, err := opavalidation.Run("testdata/fileWithLib.rego", "testdata/pod1.yaml", opavalidation.ExpectActionItemOptions{}, fwrego.InsightsInfo{}, "", "testdata/libs") + assert.NoError(t, err) + assert.Len(t, ais, 0) + ais, err = opavalidation.Run("testdata/fileWithLib.rego", "testdata/pod2.yaml", opavalidation.ExpectActionItemOptions{}, fwrego.InsightsInfo{}, "", "testdata/libs") + assert.Error(t, err) + assert.Equal(t, "1 action items were returned but none are expected", err.Error()) + assert.Len(t, ais, 1) + assert.Equal(t, "Label is missing", ais[0].Title) +} diff --git a/pkg/opavalidation/testdata/fileWithLib.rego b/pkg/opavalidation/testdata/fileWithLib.rego new file mode 100644 index 0000000..2cd69cd --- /dev/null +++ b/pkg/opavalidation/testdata/fileWithLib.rego @@ -0,0 +1,17 @@ +package fairwinds + +import data.utils.array_contains + +labelrequired[actionItem] { + requiredLabelValue := "development" + provided := [input.metadata.labels[_]] + not array_contains(provided, requiredLabelValue) + description := sprintf("Label value %v is missing", [requiredLabelValue]) + actionItem := { + "title": "Label is missing", + "description": description, + "severity": .2, + "remediation": "Add the label", + "category": "Reliability" + } +} \ No newline at end of file diff --git a/pkg/opavalidation/testdata/libs/invalid.rego b/pkg/opavalidation/testdata/libs/invalid.rego new file mode 100644 index 0000000..d800886 --- /dev/null +++ b/pkg/opavalidation/testdata/libs/invalid.rego @@ -0,0 +1 @@ +123 \ No newline at end of file diff --git a/pkg/opavalidation/testdata/libs/invalidFairwindsLib.rego b/pkg/opavalidation/testdata/libs/invalidFairwindsLib.rego new file mode 100644 index 0000000..2cd69cd --- /dev/null +++ b/pkg/opavalidation/testdata/libs/invalidFairwindsLib.rego @@ -0,0 +1,17 @@ +package fairwinds + +import data.utils.array_contains + +labelrequired[actionItem] { + requiredLabelValue := "development" + provided := [input.metadata.labels[_]] + not array_contains(provided, requiredLabelValue) + description := sprintf("Label value %v is missing", [requiredLabelValue]) + actionItem := { + "title": "Label is missing", + "description": description, + "severity": .2, + "remediation": "Add the label", + "category": "Reliability" + } +} \ No newline at end of file diff --git a/pkg/opavalidation/testdata/libs/utils.rego b/pkg/opavalidation/testdata/libs/utils.rego new file mode 100644 index 0000000..271c09f --- /dev/null +++ b/pkg/opavalidation/testdata/libs/utils.rego @@ -0,0 +1,5 @@ +package utils + +array_contains(array, elem) { + lower(array[_]) = lower(elem) +} \ No newline at end of file diff --git a/pkg/opavalidation/testdata/pod1.yaml b/pkg/opavalidation/testdata/pod1.yaml new file mode 100644 index 0000000..81738bf --- /dev/null +++ b/pkg/opavalidation/testdata/pod1.yaml @@ -0,0 +1,12 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: policy-test + labels: + department: development +spec: + replicas: 1 + template: + spec: + containers: + - image: busybox \ No newline at end of file diff --git a/pkg/opavalidation/testdata/pod2.yaml b/pkg/opavalidation/testdata/pod2.yaml new file mode 100644 index 0000000..4af20d9 --- /dev/null +++ b/pkg/opavalidation/testdata/pod2.yaml @@ -0,0 +1,12 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: policy-test + labels: + department: hr +spec: + replicas: 1 + template: + spec: + containers: + - image: busybox \ No newline at end of file