From 5c01559de571d90b5f99b0d69990196cac4a7937 Mon Sep 17 00:00:00 2001 From: ivanfetch-fw <87394843+ivanfetch-fw@users.noreply.github.com> Date: Tue, 5 Jul 2022 11:45:55 -0600 Subject: [PATCH] OPA policy validation supports specifying whether an action-item is expected (#66) --- cmd/insights/validate_opa.go | 10 ++++-- pkg/opavalidation/opavalidation.go | 40 +++++++++++++++------ pkg/opavalidation/types.go | 56 ++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/cmd/insights/validate_opa.go b/cmd/insights/validate_opa.go index 48299d8..4515947 100644 --- a/cmd/insights/validate_opa.go +++ b/cmd/insights/validate_opa.go @@ -15,6 +15,7 @@ import ( ) var regoFileName, objectFileName, batchDir, objectNamespaceOverride, insightsInfoCluster, insightsInfoContext string +var expectActionItem opavalidation.ExpectActionItemOptions // OPACmd represents the validate opa command var OPACmd = &cobra.Command{ @@ -34,7 +35,7 @@ var OPACmd = &cobra.Command{ os.Exit(1) } if regoFileName != "" { - _, err := opavalidation.Run(regoFileName, objectFileName, fwrego.InsightsInfo{InsightsContext: insightsInfoContext, Cluster: insightsInfoCluster}, objectNamespaceOverride) + _, err := opavalidation.Run(regoFileName, objectFileName, expectActionItem, fwrego.InsightsInfo{InsightsContext: insightsInfoContext, Cluster: insightsInfoCluster}, objectNamespaceOverride) if err != nil { fmt.Printf("OPA policy failed validation: %v\n", err) os.Exit(1) @@ -43,7 +44,7 @@ var OPACmd = &cobra.Command{ } if batchDir != "" { - _, failedPolicies, err := opavalidation.RunBatch(batchDir, fwrego.InsightsInfo{InsightsContext: insightsInfoContext, Cluster: insightsInfoCluster}, objectNamespaceOverride) + _, failedPolicies, err := opavalidation.RunBatch(batchDir, expectActionItem, fwrego.InsightsInfo{InsightsContext: insightsInfoContext, Cluster: insightsInfoCluster}, objectNamespaceOverride) fmt.Println() // separate output from RunBatch if err != nil { fmt.Printf("OPA policies failed validation: %v\n", err) @@ -82,8 +83,11 @@ func init() { validateCmd.AddCommand(OPACmd) OPACmd.Flags().StringVarP(&batchDir, "batch-directory", "b", "", "A directory containing OPA policy .rego files and corresponding Kubernetes manifest .yaml input files to validate. This option validates multiple OPA policies at once, and is mutually exclusive with the rego-file option.") OPACmd.Flags().StringVarP(®oFileName, "rego-file", "r", "", "An OPA policy file containing rego to validate. The --kube-object-file option is also required. This option validates a single policy, and is mutually exclusive with the batch-directory option.") - OPACmd.Flags().StringVarP(&objectFileName, "kube-object-file", "k", "", "A Kubernetes manifest to provide as input when validating a single OPA policy. This option is mutually exclusive with the batch-directory option.") + OPACmd.Flags().StringVarP(&objectFileName, "kube-object-file", "k", "", "A Kubernetes manifest to provide as input when validating a single OPA policy. This option is mutually exclusive with the batch-directory option. A manifest file ending in a .success.yaml extension is expected to return 0 action items. A manifest file ending in a .failure.yaml extension is expected to output one action item. See also the --expect-action-item option.") + OPACmd.Flags().StringVarP(&expectActionItem.SuccessFileExtension, "kube-manifest-success-ext", "e", ".success.yaml", "The extension for a Kubernetes manifest file name which, if found, indicates an OPA policy is NOT expected to return an action item.") + OPACmd.Flags().StringVarP(&expectActionItem.FailureFileExtension, "kube-manifest-failure-ext", "E", ".failure.yaml", "The extension for a Kubernetes manifest file name which, if found, indicates an OPA policy is expected to return an action item.") 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().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 2ed689d..2598104 100644 --- a/pkg/opavalidation/opavalidation.go +++ b/pkg/opavalidation/opavalidation.go @@ -16,6 +16,7 @@ import ( "github.com/open-policy-agent/opa/topdown" "github.com/open-policy-agent/opa/types" "github.com/sirupsen/logrus" + "github.com/thoas/go-funk" ) const ( @@ -24,7 +25,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, insightsInfo fwrego.InsightsInfo, objectNamespaceOverride string) (actionItems, error) { +func Run(regoFileName, objectFileName string, expectAIOptions ExpectActionItemOptions, insightsInfo fwrego.InsightsInfo, objectNamespaceOverride string) (actionItems, error) { b, err := ioutil.ReadFile(regoFileName) if err != nil { return nil, fmt.Errorf("error reading OPA policy %s: %v", regoFileName, err) @@ -47,8 +48,12 @@ func Run(regoFileName, objectFileName string, insightsInfo fwrego.InsightsInfo, if err != nil { return actionItems, err } - if len(actionItems) != 1 { - return actionItems, fmt.Errorf("%d action items were returned, but 1 is required", len(actionItems)) + expectAI := expectAIOptions.ForFileName(objectFileName) + if expectAI && len(actionItems) != 1 { + return actionItems, fmt.Errorf("%d action items were returned, but 1 is expected", len(actionItems)) + } + if !expectAI && len(actionItems) > 0 { + return actionItems, fmt.Errorf("%d action items were returned but none are expected", len(actionItems)) } return actionItems, nil } @@ -57,21 +62,34 @@ func Run(regoFileName, objectFileName string, insightsInfo fwrego.InsightsInfo, // not return the actionItems from each call to Run(), as there would not be correlation of // actionItems to their OPA policy. // This is meant to be called from a cobra.Command{}. -func RunBatch(batchDir string, insightsInfo fwrego.InsightsInfo, objectNamespaceOverride string) (successfulPolicies, failedPolicies []string, err error) { +// 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) { regoFiles, err := FindFilesWithExtension(batchDir, ".rego") if err != nil { return successfulPolicies, failedPolicies, fmt.Errorf("unable to list .rego files: %v", err) } for _, regoFileName := range regoFiles { - objectFileName := strings.TrimSuffix(regoFileName, filepath.Ext(regoFileName)) + ".yaml" - logrus.Infof("Starting validation of OPA policy %s with input %s\n", regoFileName, objectFileName) - _, err := Run(regoFileName, objectFileName, insightsInfo, objectNamespaceOverride) - if err != nil { - logrus.Errorf("Failed validation of OPA policy %s: %v\n", regoFileName, err) + objectFileNames, ok := expectAIOptions.getObjectFileNamesForPolicy(regoFileName) + if !ok { + logrus.Errorf("No Kubernetes manifest files found to use as input for validation of OPA policy %s", regoFileName) failedPolicies = append(failedPolicies, regoFileName) - } else { - logrus.Infof("Success validating OPA policy %s\n", regoFileName) + continue + } + 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) + if err != nil { + logrus.Errorf("Failed validation of OPA policy %s using input %s: %v\n", regoFileName, objectFileName, err) + if !funk.ContainsString(failedPolicies, regoFileName) { + failedPolicies = append(failedPolicies, regoFileName) + } + } + } + if !funk.ContainsString(failedPolicies, regoFileName) { successfulPolicies = append(successfulPolicies, regoFileName) + logrus.Infof("Success validating OPA policy %s\n", regoFileName) } } if len(failedPolicies) > 0 { diff --git a/pkg/opavalidation/types.go b/pkg/opavalidation/types.go index 75af61a..283308f 100644 --- a/pkg/opavalidation/types.go +++ b/pkg/opavalidation/types.go @@ -3,6 +3,8 @@ package opavalidation import ( "errors" "fmt" + "os" + "path/filepath" "strings" "github.com/fatih/color" @@ -157,3 +159,57 @@ func (AIs actionItems) setEventType(ET string) { AIs[n].EventType = ET } } + +// ExpectActionItemOptions bundles multiple settings about whether and when +// OPA policies are expected to output an action item. +// If a Kubernetes manifest file has the SuccessFileExtension, no action item +// is expected. +// If a Kubernetes manifest file has the FailureFileExtension, an action item +// is expected. +// The default expectation of an action item is used when the Kubernetes +// manifest file has neither file extension. +type ExpectActionItemOptions struct { + Default bool // Used if none of the below filename extensions applies. + SuccessFileExtension, FailureFileExtension string +} + +// ForFileName returns true if the given Kubernetes manifest file name should +// expectan OPA policy to output an action item. +func (o ExpectActionItemOptions) ForFileName(fileName string) bool { + LCFileName := strings.ToLower(fileName) + LCSuccessExtension := strings.ToLower(o.SuccessFileExtension) + LCFailureExtension := strings.ToLower(o.FailureFileExtension) + if strings.HasSuffix(LCFileName, LCSuccessExtension) { + logrus.Debugf("ExpectActionItem=%v for Kube manifest file %s due to its file extension %q", false, fileName, o.SuccessFileExtension) + return false + } + if strings.HasSuffix(LCFileName, LCFailureExtension) { + logrus.Debugf("ExpectActionItem=%v for Kube manifest file %s due to its file extension %q", true, fileName, o.FailureFileExtension) + return true + } + logrus.Debugf("ExpectActionItem=%v for Kube manifest file %s due to the default or the command-line flag.", o.Default, fileName) + return o.Default +} + +// getObjectFileNamesForPolicy returns a list of existing file names matching +// the pattern {base rego file name}.yaml|.success.yaml|.failure.yaml (the +// latter two being configurable via the expectActionItemOptions struct). +func (o ExpectActionItemOptions) getObjectFileNamesForPolicy(regoFileName string) (objectFileNames []string, foundAny bool) { + baseFileName := strings.TrimSuffix(regoFileName, filepath.Ext(regoFileName)) + var lookForFileNames []string = []string{ + baseFileName + ".yaml", + baseFileName + o.SuccessFileExtension, + baseFileName + o.FailureFileExtension} + logrus.Debugf("Looking for these object files for policy %s: %v", regoFileName, lookForFileNames) + for _, potentialFileName := range lookForFileNames { + _, err := os.Stat(potentialFileName) + if err == nil { + objectFileNames = append(objectFileNames, potentialFileName) + foundAny = true + } + } + if foundAny { + logrus.Debugf("Matched these object files for policy %s: %v", regoFileName, objectFileNames) + } + return +}