diff --git a/.circleci/config.yml b/.circleci/config.yml index d782e92..79f4211 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,6 +12,7 @@ jobs: - run: sudo chmod -R 777 /usr/local - run: make test - run: make build + - run: cp plugin/plugin.yaml build/. - persist_to_workspace: root: bin paths: @@ -22,6 +23,7 @@ jobs: - hcunit.exe - hcunit_osx - hcunit_unix + - plugin.yaml release: docker: - image: socialengine/github-release diff --git a/README.md b/README.md index 83d7d7f..50bbb5e 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,62 @@ Helm Chart Unit: helps to unit test rendering of your templates using policies -## Download Binaries +## Usage as a Helm Plugin: + +```bash +$> echo "install the latest version of the plugin" +$> helm plugin install https://github.com/xchapter7x/hcunit/releases/latest/download/hcunit_plugin.tgz +Installed plugin: unit + +$> echo "you might have have to make the plugin binaries executable" +$> helm env | grep "HELM_PLUGIN" | awk -F"=" '{print $2}' | awk -F\" '{print "chmod +x "$2"/hcunit_plugin/hcunit*"}' |sh + +$> echo "lets run some tests of our templates' logic" +$> helm unit -t templates -c policy/values_toggle_on.yaml -p policy/testing_toggle_on.rego +PASS: data.main.expect["another passing case 123"] +PASS: data.main.expect["force passing"] +PASS: data.main.expect["another passing case"] +PASS: data.main.expect["force passing abc"] +[SUCCESS] Your Helm Chart complies with all policies! + +$> echo "lets explore the available flags for the plugin call" +$> helm unit --help +Usage: + hcunit_osx [OPTIONS] eval [eval-OPTIONS] + +given a OPA/Rego Policy one can evaluate if the rendered templates of a chart using a given values file meet the defined rules of the policy or not + +Help Options: + -h, --help Show this help message + +[eval command options] + -t, --template= path to yaml template you would like to render + -c, --values= path to values file you would like to use for rendering + -p, --policy= path to rego policies to evaluate against rendered templates + -n, --namespace= policy namespace to query for rules + -v, --verbose prints tracing output to stdout + +``` + + + +## Usage as a Standalone CLI... Download Binaries https://github.com/xchapter7x/hcunit/releases/latest -## About hcunit -- Uses [OPA and Rego](https://www.openpolicyagent.org/) to evaluate the yaml to see if it meets your expectations -- By convention hcunit will run any rules in your given rego file or recursively in a given directory as long as that rule takes the form `expect ["..."] { ... } `. it is a good idea to define the hash value within the rule so it prints during a `--verbose` call -- Your policy rules will have access to a input object. This object will be a hashmap of your rendered templates, with the hash being the filename, and the value being an object representation of the rendered yaml. It will also contain a hash for the NOTES file, which will be a string. -- uses helm's packages to render the templates so, it should yield identical output as the `helm template` command + + +## Notes on Syntax and Rego + +Rego is a Policy Language for the Open Policy Agent eco system. We use rego here as our testing DSL. Any rego rule which is an `assert` or `expect` will get executed and must evaluated to true. The gist is that everything between the `{}` is a `rule`. Everything between `{}` should evaluate to `true`. Assignments yield true, and if any statement in the `{}` block is `false` then the entire rule will return `false` and therfore fail our test case. + +For more information you can try: https://www.openpolicyagent.org/docs/latest/#rego + +Or + +for a Online playground: https://play.openpolicyagent.org/ + + + ## Options @@ -40,22 +88,21 @@ Available commands: ───────┼─────────────────────────────────────────────────────────────── 1 │ package main 2 │ - 3 │ expect [msg] { - 4 │ msg = "noop pass rule" - 5 │ true - 6 │ } - 7 │ - 8 │ expect [msg] { - 9 │ msg = "we should have values and secrets" - 10 │ input["values.yaml"] - 11 │ n = input["web-secrets.yaml"].metadata.name - 12 │ n == "hcunit-name-web" - 13 │ } + 3 │ assert ["this should always be true b/c its true"] { + 4 │ true + 5 │ } + 6 │ + 7 │ assert ["when web is enabled then namespace is toggled on"] { + 8 | "true" == input["values"].web.enabled + 9 │ "Namespace" == input["namespace.yaml"].kind + 10 │ } ───────┴─────────────────────────────────────────────────────────────── 000@000-000 [00:00:00] [helm-charts/concourse] [master *] -> % hcunit eval -t templates/ -c values.yaml -p policy/testing.rego -[PASS] Your policy rules have been run successfully! +PASS: data.main.assert["this should always be true b/c its true"] +PASS: data.main.assert["when web is enabled then namespace is toggled on"] +[SUCCESS] Your Helm Chart complies with all policies! 000@000-000 [00:00:00] [helm-charts/concourse] [master *] -> % cat policy/testing_fail.rego @@ -64,35 +111,28 @@ Available commands: ───────┼─────────────────────────────────────────────────────────────── 1 │ package main 2 │ - 3 │ expect [msg] { - 4 │ msg = "noop pass rule" - 5 │ true - 6 │ } - 7 │ - 8 │ expect [msg] { - 9 │ msg = "we should have values and secrets" - 10 │ input["values.yaml"] - 11 │ n = input["web-secrets.yaml"].metadata.name - 12 │ n == "WRONGNAME" - 13 │ } + 3 │ assert ["this should always be true b/c its true"] { + 4 │ false + 5 │ } + 6 │ + 7 │ assert ["when web is enabled then namespace is toggled on"] { + 8 | "true" == input["values"].web.enabled + 9 │ "NamespaceWrongKind" == input["namespace.yaml"].kind + 10 │ } ───────┴─────────────────────────────────────────────────────────────── 000@000-000 [00:00:00] [helm-charts/concourse] [master *] -> % hcunit eval -t templates/ -c values.yaml -p policy/testing_fail.rego -[FAIL] Your policy rules are violated in your rendered output! -your policy failed - +FAIL: data.main.assert["this should always be true b/c its true"] +FAIL: data.main.assert["when web is enabled then namespace is toggled on"] +[FAILURE] Policy violations found on the Helm Chart! ``` - - - - - - - - - - - +## About hcunit +- Uses [OPA and Rego](https://www.openpolicyagent.org/) to evaluate the yaml to see if it meets your expectations +- By convention hcunit will run any rules in your given rego file or recursively in a given directory as long as that rule takes the form `assert ["some behavior"] { ... } ` or `expect ["some other behavior"] { ... } `. +- using variables or duplicate values in the hash for your tests is prohibited by hcunit. Reason being duplicate hashes opens up the potential for inconsistent/confusing results. +- Your policy rules will have access to a input object. This object will be a hashmap of your rendered templates, with the hash being the filename, and the value being an object representation of the rendered yaml. It will also contain a hash for the NOTES file, which will be a string. +- uses helm's packages to render the templates so, it should yield identical output as the `helm template` command +- supports multiple values.yml file inputs, does not yet support values set as flags in the cli call. diff --git a/bin/create_new_release.sh b/bin/create_new_release.sh index bf073ea..b98df60 100755 --- a/bin/create_new_release.sh +++ b/bin/create_new_release.sh @@ -6,9 +6,17 @@ git pull --tags >/dev/null || true echo "generate a rc build number" BUMP_SEMVER_PATCH=$(git tag -l | grep -v "-" | tail -1 | awk -F. '{print $1"."$2"."$3+1}') +if [[ ${BUMP_SEMVER_PATCH} -eq "" ]]; then + BUMP_SEMVER_PATCH="0.1.0" +fi BUMP_SEMVER_RC=$(git tag -l | grep "${BUMP_SEMVER_PATCH}" | grep -e "-rc" | tail -1 | awk -F"-rc." '{print $2+1}') +if [[ ${BUMP_SEMVER_RC} -eq "" ]]; then + BUMP_SEMVER_RC="0" +fi SEMVER=${BUMP_SEMVER_PATCH}-rc.${BUMP_SEMVER_RC} echo "tag id is: "${SEMVER} +echo "creating plugin tarball" +tar -czvf build/hcunit_plugin.tgz --directory=build hcunit_osx hcunit.exe hcunit_unix plugin.yaml echo "creating release" github-release release -t ${SEMVER} -p echo "uploading files" diff --git a/cmd/hcunit/main.go b/cmd/hcunit/main.go index 5bd27a0..db38722 100644 --- a/cmd/hcunit/main.go +++ b/cmd/hcunit/main.go @@ -47,7 +47,7 @@ func init() { parser.AddCommand( "eval", "evaluate a policy on a chart + values", - "given a OPS/Rego Policy one can evaluate if the rendered templates of a chart using a given values file meet the defined rules of the policy or not", + "given a OPA/Rego Policy one can evaluate if the rendered templates of a chart using a given values file meet the defined rules of the policy or not", new(commands.EvalCommand), ) } diff --git a/go.mod b/go.mod index a4cc5f3..962fc4a 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,13 @@ require ( github.com/Masterminds/sprig v2.22.0+incompatible // indirect github.com/OneOfOne/xxhash v1.2.5 // indirect github.com/cyphar/filepath-securejoin v0.2.2 // indirect + github.com/ghodss/yaml v1.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/golang/protobuf v1.3.1 - github.com/helm/helm v2.14.3+incompatible github.com/huandu/xstrings v1.2.0 // indirect github.com/imdario/mergo v0.3.8 // indirect github.com/jessevdk/go-flags v1.4.0 + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/mitchellh/copystructure v1.0.0 // indirect github.com/onsi/gomega v1.7.0 github.com/open-policy-agent/opa v0.14.2 @@ -23,7 +24,7 @@ require ( github.com/sergi/go-diff v1.0.0 github.com/yashtewari/glob-intersection v0.0.0-20180916065949-5c77d914dd0b // indirect golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect - gopkg.in/yaml.v2 v2.2.4 + golang.org/x/sys v0.0.0-20191008105621-543471e840be // indirect gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652 k8s.io/apimachinery v0.0.0-20191006235458-f9f2f3f8ab02 // indirect k8s.io/helm v2.14.3+incompatible diff --git a/go.sum b/go.sum index 502fc2f..ab505c0 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680 h1:ZktWZesgun21uEDrwW7iEV1zPCGQldM2atlJZ3TdvVM= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= @@ -46,8 +48,6 @@ github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/helm/helm v2.14.3+incompatible h1:qcA+YrKE8026DX8h4HANBnx+hKrB2oOZYpS0tMtmR/A= -github.com/helm/helm v2.14.3+incompatible/go.mod h1:ahXhuvluW4YnSL6W6hDVetZsVK8Pv4BP8OwKli7aMqo= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= @@ -66,6 +66,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= @@ -122,6 +124,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f h1:25KHgbfyiSm6vwQLbM3zZIe1v9p/3ea4Rz+nnM5K/i4= golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be h1:QAcqgptGM8IQBC9K/RC4o+O9YmqEm0diQn9QmZw/0mU= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/makefile b/makefile index 2a75db1..ed438e8 100644 --- a/makefile +++ b/makefile @@ -33,16 +33,19 @@ build-darwin: GOOS=darwin \ GOARCH=amd64 \ $(GOBUILD) -ldflags "-X main.Buildtime=$(BUILDTIME) -X main.Version=$(SEMVER) -X main.Platform=OSX/amd64" -v -o $(BINARY_DIR)/$(BINARY_DARWIN) $(CLI_PATH) + chmod +x $(BINARY_DIR)/$(BINARY_DARWIN) build-win: CGO_ENABLED=0 \ GOOS=windows \ GOARCH=amd64 \ $(GOBUILD) -ldflags "-X main.Buildtime=$(BUILDTIME) -X main.Version=$(SEMVER) -X main.Platform=Windows/amd64"-v -o $(BINARY_DIR)/$(BINARY_WIN) $(CLI_PATH) + chmod +x $(BINARY_DIR)/$(BINARY_WIN) build-linux: CGO_ENABLED=0 \ GOOS=linux \ GOARCH=amd64 \ $(GOBUILD) -ldflags "-X main.Buildtime=$(BUILDTIME) -X main.Version=$(SEMVER) -X main.Platform=Linux/amd64"-v -o $(BINARY_DIR)/$(BINARY_UNIX) $(CLI_PATH) + chmod +x $(BINARY_DIR)/$(BINARY_UNIX) release: ./bin/create_new_release.sh diff --git a/pkg/commands/eval.go b/pkg/commands/eval.go index 2f6bfbc..06aee61 100644 --- a/pkg/commands/eval.go +++ b/pkg/commands/eval.go @@ -4,20 +4,18 @@ import ( "bytes" "fmt" "io" - "io/ioutil" "os" - "path/filepath" - - yaml "gopkg.in/yaml.v3" ) +const valuesHashName = "values" + type EvalCommand struct { Writer io.Writer - Template string `short:"t" long:"template" description:"path to yaml template you would like to render"` - Values string `short:"c" long:"values" description:"path to values file you would like to use for rendering"` - Policy string `short:"p" long:"policy" description:"path to rego policies to evaluate against rendered templates"` - Namespace string `short:"n" long:"namespace" description:"policy namespace to query for rules"` - Verbose bool `short:"v" long:"verbose" description:"prints tracing output to stdout"` + Template string `short:"t" long:"template" description:"path to yaml template you would like to render"` + Values []string `short:"c" long:"values" description:"path to values file(s) you would like to use for rendering"` + Policy string `short:"p" long:"policy" description:"path to rego policies to evaluate against rendered templates"` + Namespace string `short:"n" long:"namespace" description:"policy namespace to query for rules"` + Verbose bool `short:"v" long:"verbose" description:"prints tracing output to stdout"` } func (s *EvalCommand) Execute(args []string) error { @@ -32,21 +30,14 @@ func (s *EvalCommand) Execute(args []string) error { return InvalidPolicyPath } fileFile.Close() - - renderedOutput, err := validateAndRender(s.Template, s.Values) - if err != nil { - return fmt.Errorf("error while rendering: %w", err) - } - - var valuesConfig interface{} - valuesFile, err := ioutil.ReadFile(s.Values) + valuesConfig, err := mergeValues(s.Values) if err != nil { - return fmt.Errorf("yamlFile.Get err #%v ", err) + return fmt.Errorf("failed merging values files %w ", err) } - err = yaml.Unmarshal(valuesFile, &valuesConfig) + renderedOutput, err := validateAndRender(s.Template, valuesConfig) if err != nil { - return fmt.Errorf("Unmarshal %s failed: %v", valuesFile, err) + return fmt.Errorf("error while rendering: %w", err) } policyInput, err := UnmarshalYamlMap(renderedOutput) @@ -54,16 +45,19 @@ func (s *EvalCommand) Execute(args []string) error { return fmt.Errorf("formatting policy input failed: %w", err) } - policyInput[filepath.Base(s.Values)] = valuesConfig + policyInput[valuesHashName] = valuesConfig return evalPolicyOnInput(s.Writer, s.Policy, s.Namespace, policyInput) } func (s *EvalCommand) setDefaults() { - s.Writer = new(bytes.Buffer) - if s.Verbose { + if s.Writer == nil { s.Writer = os.Stdout } + if !s.Verbose { + s.Writer = new(bytes.Buffer) + } + if s.Namespace == "" { s.Namespace = "main" } diff --git a/pkg/commands/eval_test.go b/pkg/commands/eval_test.go index 062e639..634d21d 100644 --- a/pkg/commands/eval_test.go +++ b/pkg/commands/eval_test.go @@ -14,94 +14,139 @@ func TestEvalCommand(t *testing.T) { for _, tt := range []struct { name string template string - values string + values []string policy string failsWith error skip bool + verbose bool }{ { name: "invalid policy path given", template: "testdata/templates/something.yml", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, failsWith: commands.InvalidPolicyPath, }, { name: "passing policy on a single template", template: "testdata/templates/something.yml", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/passing/passing.rego", failsWith: nil, }, + { + name: "duplicate test hash", + template: "testdata/templates", + values: []string{"testdata/values.yml"}, + policy: "testdata/policy/individuals/duplicate_keynames.rego", + failsWith: commands.DuplicatePolicyFailure, + }, { name: "passing policy on a template directory", template: "testdata/templates", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/passing/passing.rego", failsWith: nil, }, { name: "failing policy on a single template", template: "testdata/templates/something.yml", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/failing/failing.rego", failsWith: commands.PolicyFailure, }, { name: "failing policy on a template directory", template: "testdata/templates", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/failing/failing.rego", failsWith: commands.PolicyFailure, }, { name: "multifile failing policy on a template directory", template: "testdata/templates", - values: "testdata/values.yml", - policy: "testdata/policy", + values: []string{"testdata/values.yml"}, + policy: "testdata/policy/failing", failsWith: commands.PolicyFailure, }, { name: "multifile passing policy on a template directory", template: "testdata/templates", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/passing", failsWith: nil, }, { name: "has a properly structured input object", template: "testdata/templates", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/individuals/parse_input.rego", failsWith: nil, }, { name: "values.yml available in input", template: "testdata/templates", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/individuals/values_in_input.rego", failsWith: nil, }, { name: "templates available in input", template: "testdata/templates", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/individuals/templates_in_input.rego", failsWith: nil, }, { name: "supports assert[_] rule query", template: "testdata/templates", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/individuals/alternate_keyword.rego", failsWith: nil, }, + { + name: "supports mulitple values files", + template: "testdata/templates", + values: []string{"testdata/values.yml", "testdata/added_values.yml"}, + policy: "testdata/policy/individuals/multiple_values.rego", + failsWith: nil, + }, + { + name: "supports mulitple values files last file wins", + template: "testdata/templates", + values: []string{"testdata/added_values.yml", "testdata/values.yml"}, + policy: "testdata/policy/individuals/multiple_values.rego", + failsWith: commands.PolicyFailure, + }, { name: "should error when no query match in rego", template: "testdata/templates", - values: "testdata/values.yml", + values: []string{"testdata/values.yml"}, policy: "testdata/policy/individuals/no_keyword.rego", failsWith: commands.UnmatchedQuery, }, + { + name: "no passing assertions", + template: "testdata/templates", + values: []string{"testdata/values.yml"}, + policy: "testdata/policy/individuals/no_passing_valid.rego", + failsWith: commands.PolicyFailure, + }, + { + name: "verbosity on success should print trace information", + template: "testdata/templates", + values: []string{"testdata/values.yml", "testdata/added_values.yml"}, + policy: "testdata/policy/individuals/multiple_values.rego", + failsWith: nil, + verbose: true, + }, + { + name: "verbosity on failure should print trace information", + template: "testdata/templates", + values: []string{"testdata/values.yml"}, + policy: "testdata/policy/individuals/no_passing_valid.rego", + failsWith: commands.PolicyFailure, + verbose: true, + }, } { t.Run(tt.name, func(t *testing.T) { if tt.skip { @@ -114,12 +159,21 @@ func TestEvalCommand(t *testing.T) { Template: tt.template, Policy: tt.policy, Values: tt.values, + Verbose: tt.verbose, } err := evalCmd.Execute([]string{}) if err != nil && !errors.Is(err, tt.failsWith) { t.Errorf("expected error:\n%v\ngot:\n%v", tt.failsWith, err) } + if !tt.verbose && stdOut.Len() > 0 { + t.Errorf("when verbose is off this should always be empty, but it contains %v bytes", stdOut.Len()) + } + + if tt.verbose && stdOut.Len() == 0 { + t.Errorf("we expected to print verbose trace information, but it is empty") + } + if err == nil && tt.failsWith != nil { t.Errorf("expected a failing policy %w but no failures found", tt.failsWith) } diff --git a/pkg/commands/render.go b/pkg/commands/render.go index fc0d8b9..c649f15 100644 --- a/pkg/commands/render.go +++ b/pkg/commands/render.go @@ -9,13 +9,18 @@ import ( type RenderCommand struct { Writer io.Writer - Template string `short:"t" long:"template" description:"path to yaml template you would like to render"` - Values string `short:"c" long:"values" description:"path to values file you would like to use for rendering"` + Template string `short:"t" long:"template" description:"path to yaml template you would like to render"` + Values []string `short:"c" long:"values" description:"path to values file(s) you would like to use for rendering"` } func (s *RenderCommand) Execute(args []string) error { s.setDefaults() - renderedOutput, err := validateAndRender(s.Template, s.Values) + valuesConfig, err := mergeValues(s.Values) + if err != nil { + return fmt.Errorf("failed merging values files %w ", err) + } + + renderedOutput, err := validateAndRender(s.Template, valuesConfig) if err != nil { return fmt.Errorf("error while rendering: %w", err) } diff --git a/pkg/commands/render_test.go b/pkg/commands/render_test.go index ff1b36c..4838177 100644 --- a/pkg/commands/render_test.go +++ b/pkg/commands/render_test.go @@ -14,11 +14,11 @@ func TestRenderCommand(t *testing.T) { for _, tt := range []struct { name string template string - values string + values []string contains []string }{ - {"template filepath", "testdata/templates/something.yml", "testdata/values.yml", []string{controlYaml}}, - {"template dir path", "testdata/templates", "testdata/values.yml", []string{controlYaml, controlNotes}}, + {"template filepath", "testdata/templates/something.yml", []string{"testdata/values.yml"}, []string{controlYaml}}, + {"template dir path", "testdata/templates", []string{"testdata/values.yml"}, []string{controlYaml, controlNotes}}, } { t.Run(tt.name, func(t *testing.T) { stdOut := new(bytes.Buffer) @@ -65,7 +65,7 @@ func TestRenderCommand(t *testing.T) { }, { name: "no template", - render: &commands.RenderCommand{Values: "hi/there"}, + render: &commands.RenderCommand{Values: []string{"hi/there"}}, shouldError: true, }, { @@ -75,27 +75,27 @@ func TestRenderCommand(t *testing.T) { }, { name: "invalid template", - render: &commands.RenderCommand{Values: "hi/there", Template: "yo/yo"}, + render: &commands.RenderCommand{Values: []string{"hi/there"}, Template: "yo/yo"}, shouldError: true, }, { name: "invliad values", - render: &commands.RenderCommand{Template: "hi/There", Values: "yo/yo"}, + render: &commands.RenderCommand{Template: "hi/There", Values: []string{"yo/yo"}}, shouldError: true, }, { name: "values is not a file", - render: &commands.RenderCommand{Template: "testdata/templates/something.yml", Values: "testdata/"}, + render: &commands.RenderCommand{Template: "testdata/templates/something.yml", Values: []string{"testdata/"}}, shouldError: true, }, { name: "valid template & values file paths", - render: &commands.RenderCommand{Template: "testdata/templates/something.yml", Values: "testdata/values.yml"}, + render: &commands.RenderCommand{Template: "testdata/templates/something.yml", Values: []string{"testdata/values.yml"}}, shouldError: false, }, { name: "valid template dir & values file path", - render: &commands.RenderCommand{Template: "testdata/templates", Values: "testdata/values.yml"}, + render: &commands.RenderCommand{Template: "testdata/templates", Values: []string{"testdata/values.yml"}}, shouldError: false, }, } { diff --git a/pkg/commands/testdata/added_values.yml b/pkg/commands/testdata/added_values.yml new file mode 100644 index 0000000..7f72915 --- /dev/null +++ b/pkg/commands/testdata/added_values.yml @@ -0,0 +1,2 @@ +uiIngress: + enabled: true diff --git a/pkg/commands/testdata/policy/individuals/alternate_keyword.rego b/pkg/commands/testdata/policy/individuals/alternate_keyword.rego index a05d2c8..3dca7c8 100644 --- a/pkg/commands/testdata/policy/individuals/alternate_keyword.rego +++ b/pkg/commands/testdata/policy/individuals/alternate_keyword.rego @@ -1,5 +1,5 @@ package main assert ["input object should provide values in hash"] { - input["values.yml"] + input["values"] } diff --git a/pkg/commands/testdata/policy/individuals/duplicate_keynames.rego b/pkg/commands/testdata/policy/individuals/duplicate_keynames.rego new file mode 100644 index 0000000..84e09d1 --- /dev/null +++ b/pkg/commands/testdata/policy/individuals/duplicate_keynames.rego @@ -0,0 +1,11 @@ +package main + +assert [b] { + b = "something" + true +} + +assert [b] { + b = "something else" + true +} diff --git a/pkg/commands/testdata/policy/individuals/multiple_values.rego b/pkg/commands/testdata/policy/individuals/multiple_values.rego new file mode 100644 index 0000000..b3c09ea --- /dev/null +++ b/pkg/commands/testdata/policy/individuals/multiple_values.rego @@ -0,0 +1,5 @@ +package main + +assert ["values object should have multiple file values"] { + true == input["values"]["uiIngress"]["enabled"] +} diff --git a/pkg/commands/testdata/policy/individuals/no_keyword.rego b/pkg/commands/testdata/policy/individuals/no_keyword.rego index 55285ab..3fa937f 100644 --- a/pkg/commands/testdata/policy/individuals/no_keyword.rego +++ b/pkg/commands/testdata/policy/individuals/no_keyword.rego @@ -1,8 +1,8 @@ package main allow ["input object should provide values in hash"] { - input["values.yml"] + input["values"] } deny ["input object should provide values in hash"] { - input["values.yml"] + input["values"] } diff --git a/pkg/commands/testdata/policy/individuals/no_passing_valid.rego b/pkg/commands/testdata/policy/individuals/no_passing_valid.rego new file mode 100644 index 0000000..7a2290d --- /dev/null +++ b/pkg/commands/testdata/policy/individuals/no_passing_valid.rego @@ -0,0 +1,9 @@ +package main + +assert ["fail once"] { + false +} + +assert ["fail twice"] { + false +} diff --git a/pkg/commands/testdata/policy/individuals/values_in_input.rego b/pkg/commands/testdata/policy/individuals/values_in_input.rego index 1f7e30f..2e9b021 100644 --- a/pkg/commands/testdata/policy/individuals/values_in_input.rego +++ b/pkg/commands/testdata/policy/individuals/values_in_input.rego @@ -1,5 +1,5 @@ package main expect ["input object should provide values in hash"] { - input["values.yml"] + input["values"] } diff --git a/pkg/commands/testdata/templates/nested/something_else.yml b/pkg/commands/testdata/templates/nested/something_else.yml new file mode 100644 index 0000000..7211c39 --- /dev/null +++ b/pkg/commands/testdata/templates/nested/something_else.yml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }} + labels: + app: {{ .Release.Name }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + type: ClusterIP + clusterIP: None + ports: [] + selector: + app: {{ .Release.Name }} diff --git a/pkg/commands/util.go b/pkg/commands/util.go index f1b3992..9d5bb72 100644 --- a/pkg/commands/util.go +++ b/pkg/commands/util.go @@ -6,14 +6,17 @@ import ( "errors" "fmt" "io" + "io/ioutil" "os" "path/filepath" "regexp" "strings" "github.com/golang/protobuf/ptypes/timestamp" - "github.com/helm/helm/pkg/renderutil" + "k8s.io/helm/pkg/renderutil" + "github.com/mitchellh/colorstring" "github.com/open-policy-agent/opa/rego" + "github.com/open-policy-agent/opa/tester" "github.com/open-policy-agent/opa/topdown" yaml "gopkg.in/yaml.v3" "k8s.io/helm/pkg/chartutil" @@ -25,19 +28,66 @@ var FilepathDirUnexpected = errors.New("filepath given is a Dir. We expect a pat var UnmatchedQuery = errors.New("your given query did not yield any matches") var InvalidPolicyPath = errors.New("invalid policy path") var PolicyFailure = errors.New("your policy failed") +var DuplicatePolicyFailure = errors.New("duplicate rule names found") var expectQuery = regexp.MustCompile("^expect(_[a-zA-Z]+)*$") -func validateAndRender(template, values string) (map[string]string, error) { - templateFiles, err := validateFileOrDirPath(template) +func mergeValues(valueFiles []string) (map[string]interface{}, error) { + base := map[string]interface{}{} + + for _, filePath := range valueFiles { + currentMap := map[string]interface{}{} + + bytes, err := readFile(filePath) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(bytes, ¤tMap); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", filePath, err) + } + base = mergeMaps(base, currentMap) + } + return base, nil +} + +func mergeMaps(a, b map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(a)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + if v, ok := v.(map[string]interface{}); ok { + if bv, ok := out[k]; ok { + if bv, ok := bv.(map[string]interface{}); ok { + out[k] = mergeMaps(bv, v) + continue + } + } + } + out[k] = v + } + return out +} + +func readFile(filePath string) ([]byte, error) { + if strings.TrimSpace(filePath) == "-" { + return ioutil.ReadAll(os.Stdin) + } + return ioutil.ReadFile(filePath) +} + +func validateAndRender(templatePath string, valuesMap map[string]interface{}) (map[string]string, error) { + templateFiles, err := WalkTemplatePath(templatePath) if err != nil { return nil, fmt.Errorf("template validation failed: %w", err) } - valuesFile, err := validateFilePath(values) + values, err := yaml.Marshal(valuesMap) if err != nil { - return nil, fmt.Errorf("values validation failed: %w", err) + return nil, fmt.Errorf("couldnt marshal values: %w", err) } + valuesFile := ioutil.NopCloser(bytes.NewReader(values)) return render(valuesFile, templateFiles) } @@ -111,105 +161,114 @@ func render(values io.ReadCloser, templates map[string]io.ReadCloser) (map[strin return renderutil.Render(testChart, defaultConfig, defaultOptions) } -func validateFileOrDirPath(filePath string) (map[string]io.ReadCloser, error) { - if filePath == "" { - return nil, FilepathValueEmpty - } - - fileFile, err := os.Open(filePath) - if err != nil { - return nil, fmt.Errorf("invalid Template path given: %w", err) - } - - fileStatus, err := fileFile.Stat() - if err != nil { - return nil, fmt.Errorf("error while checking file status: %w", err) - } - - fileMode := fileStatus.Mode() - if fileMode.IsDir() { - filePointers, err := fileFile.Readdir(-1) - fileFile.Close() +//WalkTemplatePath - walk a given template path to read all +// of the templates (even nested templates) into a map +func WalkTemplatePath(templatePath string) (map[string]io.ReadCloser, error) { + templates := make(map[string]io.ReadCloser) + err := filepath.Walk(templatePath, func(path string, info os.FileInfo, err error) error { if err != nil { - return nil, fmt.Errorf("reading files from directory failed: %w", err) + return fmt.Errorf("failure accessing a path %q: %w", path, err) } - files := make(map[string]io.ReadCloser) - - for _, file := range filePointers { - filePath := fmt.Sprintf("%s/%s", filePath, file.Name()) - fileReadCloser, err := os.Open(filePath) + if !info.IsDir() { + template, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("reading file failed: %w", err) + return fmt.Errorf("reading file failed: %w", err) } - files[filePath] = fileReadCloser + templates[path] = template } + return nil + }) - return files, nil - } - - return map[string]io.ReadCloser{filePath: fileFile}, nil -} - -func validateFilePath(filePath string) (*os.File, error) { - if filePath == "" { - return nil, FilepathValueEmpty - } - - fileFile, err := os.Open(filePath) if err != nil { - return nil, fmt.Errorf("invalid Values path given: %w", err) + return nil, fmt.Errorf("error walking the path %q: %v\n", templatePath, err) } - fileStatus, err := fileFile.Stat() - if err != nil { - return nil, fmt.Errorf("error while checking file status: %w", err) - } + return templates, nil +} - fileMode := fileStatus.Mode() - if fileMode.IsDir() { - return nil, FilepathDirUnexpected +func getQueryList(policy string) map[string]int { + res := map[string]int{} + mods, _, _ := tester.Load([]string{policy}, nil) + for _, mod := range mods { + for _, rule := range mod.Rules { + if strings.HasPrefix("expect[", string(rule.Head.Name)) || + strings.HasPrefix("assert[", string(rule.Head.Name)) { + res[fmt.Sprintf("%s[%s]", rule.Head.Name, rule.Head.Key)] += 1 + } + } } - return fileFile, nil + return res } func evalPolicyOnInput(writer io.Writer, policy string, namespace string, input interface{}) error { - bufWriter := new(bytes.Buffer) + testResults := make(map[string]bool) ctx := context.Background() var results rego.ResultSet - for _, querySuffix := range []string{"expect[_]", "assert[_]"} { + queryList := getQueryList(policy) + for querySuffix, querymatches := range queryList { + if querymatches > 1 { + colorstring.Println("[red]ERROR: you are using duplicate test names or variables. This could cause test failures to NOT be detected properly") + colorstring.Println(fmt.Sprintf("[yellow]DUPLICATE KEY: %s", querySuffix)) + return DuplicatePolicyFailure + } + + queryString := fmt.Sprintf("data.%s.%s", namespace, querySuffix) buf := topdown.NewBufferTracer() - query, err := rego.New( - rego.Query(fmt.Sprintf("data.%s.%s", namespace, querySuffix)), + r := rego.New( + rego.Query(queryString), rego.Tracer(buf), rego.Load([]string{policy}, nil), - ).PrepareForEval(ctx) + ) + query, err := r.PrepareForEval(ctx) if err != nil { return fmt.Errorf("failed preparing for eval on policies: %w", err) } - r, err := query.Eval(ctx, rego.EvalInput(input)) + resultSet, err := query.Eval(ctx, rego.EvalInput(input)) if err != nil { return fmt.Errorf("failed eval on policies: %w", err) } - if len(r) > 0 { - results = append(results, r...) - topdown.PrettyTrace(bufWriter, *buf) - fmt.Fprint(writer, bufWriter.String()) + testResults[queryString] = false + for _, result := range resultSet { + + for _, expression := range result.Expressions { + if expression.Text == queryString { + testResults[queryString] = true + } + } + } + + if len(resultSet) > 0 { + results = append(results, resultSet...) } + + topdown.PrettyTrace(writer, *buf) } - if len(results) <= 0 { + if len(queryList) <= 0 { return UnmatchedQuery } - if strings.Contains(bufWriter.String(), "Fail ") { - fmt.Println("[FAIL] Your policy rules are violated in your rendered output!") + testFailed := false + for testname, passed := range testResults { + if passed { + colorstring.Print("[green]PASS: ") + fmt.Println(testname) + } else { + testFailed = true + colorstring.Print("[red]FAIL: ") + fmt.Println(testname) + } + } + + if testFailed { + colorstring.Println("[_red_][FAILURE] Policy violations found on the Helm Chart!") return PolicyFailure } - fmt.Println("[PASS] Your policy rules have been run successfully!") + colorstring.Println("[green][SUCCESS] Your Helm Chart complies with all policies!") return nil } diff --git a/pkg/commands/util_test.go b/pkg/commands/util_test.go index 23da686..4aea9d1 100644 --- a/pkg/commands/util_test.go +++ b/pkg/commands/util_test.go @@ -7,6 +7,49 @@ import ( "github.com/xchapter7x/hcunit/pkg/commands" ) +func TestWalkTemplatePath(t *testing.T) { + for _, tt := range []struct { + name string + templatePath string + nestedTemplatesSupported bool + nestedPath string + flatPath string + skip bool + }{ + { + name: "walking templates that include nested templates", + templatePath: "testdata/templates", + nestedTemplatesSupported: true, + nestedPath: "testdata/templates/nested/something_else.yml", + flatPath: "testdata/templates/something.yml", + skip: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + if tt.skip { + t.Skip("this feature is not yet activated") + } + + templates, err := commands.WalkTemplatePath(tt.templatePath) + if err != nil { + t.Errorf("We should not have failed walking templates: %v", err) + } + + if _, ok := templates[tt.nestedPath]; ok != tt.nestedTemplatesSupported { + t.Errorf( + "the template map doesnt match its expected feature support inmap:%v != supported:%v", + ok, + tt.nestedTemplatesSupported, + ) + } + + if _, ok := templates[tt.flatPath]; !ok { + t.Errorf("couldnt find expected template %s in %v", tt.flatPath, templates) + } + }) + } +} + func TestUnmarshalYamlMap(t *testing.T) { for _, tt := range []struct { name string