Skip to content

Commit

Permalink
EC-237 Add validate input and use policy config
Browse files Browse the repository at this point in the history
This commit addresses EC-237 & EC-57 which marks `ec validate
definition` as deprecated, adds `ec validate input` and allows `ec
validate input` to utilize a policy config file in a similar manner as
the `ec validate image` command.
  • Loading branch information
robnester-rh committed Dec 11, 2023
1 parent e429725 commit 6306bc1
Show file tree
Hide file tree
Showing 10 changed files with 1,361 additions and 8 deletions.
1 change: 1 addition & 0 deletions cmd/validate/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func validateDefinitionCmd(validate definitionValidationFn) *cobra.Command {
--data git::https://github.com/enterprise-contract/ec-policies//example/data
`),

Deprecated: "please use \"ec validate input\" instead.",
RunE: func(cmd *cobra.Command, args []string) error {
var allErrors error
report := definition.NewReport()
Expand Down
14 changes: 8 additions & 6 deletions cmd/validate/definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"

hd "github.com/MakeNowJust/heredoc"
Expand Down Expand Up @@ -73,7 +75,7 @@ func TestValidateDefinitionFileCommandOutput(t *testing.T) {
],
"success": true,
"ec-version": "development"
}`, out.String())
}`, strings.Split(out.String(), "\n")[1])
}

func TestValidateDefinitionFilePolicySources(t *testing.T) {
Expand Down Expand Up @@ -134,22 +136,22 @@ func TestDefinitionFileOutputFormats(t *testing.T) {
}{
{
name: "default output",
expectedStdout: testJSONText,
expectedStdout: fmt.Sprintf("Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n%s",testJSONText),
},
{
name: "json stdout",
output: []string{"--output", "json"},
expectedStdout: testJSONText,
expectedStdout: fmt.Sprintf("Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n%s", testJSONText),
},
{
name: "yaml stdout",
output: []string{"--output", "yaml"},
expectedStdout: testYAMLTest,
expectedStdout: fmt.Sprintf("Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n%s",testYAMLTest),
},
{
name: "json and yaml to file",
output: []string{"--output", "json=out.json", "--output", "yaml=out.yaml"},
expectedStdout: "",
expectedStdout: "Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n",
expectedFiles: map[string]string{
"out.json": testJSONText,
"out.yaml": testYAMLTest,
Expand Down Expand Up @@ -215,7 +217,7 @@ func TestValidateDefinitionFileCommandErrors(t *testing.T) {

err := cmd.Execute()
assert.Error(t, err, "2 errors occurred:\n\t* /path/file1.yaml\n\t* /path/file2.yaml\n")
assert.Equal(t, "", out.String())
assert.Equal(t, "Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n", out.String())
}

func TestStrictOutput(t *testing.T) {
Expand Down
258 changes: 258 additions & 0 deletions cmd/validate/input.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// Copyright The Enterprise Contract Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package validate

import (
"context"
"errors"
"fmt"
"sort"
"sync"

hd "github.com/MakeNowJust/heredoc"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
"github.com/spf13/afero"

"github.com/enterprise-contract/ec-cli/internal/evaluator"
"github.com/enterprise-contract/ec-cli/internal/format"
"github.com/enterprise-contract/ec-cli/internal/input"
"github.com/enterprise-contract/ec-cli/internal/output"
"github.com/enterprise-contract/ec-cli/internal/policy"
"github.com/enterprise-contract/ec-cli/internal/policy/source"
"github.com/enterprise-contract/ec-cli/internal/utils"
"github.com/spf13/cobra"
)

type InputValidationFunc func(context.Context, string, policy.Policy, []string, string, bool) (*output.Output, error)

func validateInputCmd(validate InputValidationFunc) *cobra.Command {
var data = struct {
effectiveTime string
filePaths []string
info bool
namespaces []string
output []string
outputFile string
policy policy.Policy
policyConfiguration string
strict bool
}{
strict: true,
info: false,
}
cmd := &cobra.Command{
Use: "input",
Short: "Validate arbitrary JSON or yaml file input conformance with the Enterprise Contract",
Long: "",
Example: "",
PreRunE: func(cmd *cobra.Command, args []string) (allErrors error) {
ctx := cmd.Context()

// Check if policyConfiguration is a git url. If so, try to download a config file from git.
// If successful we write that into the data.policyConfiguration var.
if source.SourceIsGit(data.policyConfiguration) {
log.Debugf("Fetching policy config from git url %s", data.policyConfiguration)

// Create a temporary dir to download the config. This is separate from the workDir usd
// later for downloading policy sources, but it doesn't matter because this dir is not
// used again once the config file has been read.
fs := utils.FS(ctx)
tmpDir, err := utils.CreateWorkDir(fs)
if err != nil {
allErrors = multierror.Append(allErrors, err)
return
}
defer utils.CleanupWorkDir(fs, tmpDir)

// Git download and find a suitable config file
configFile, err := source.GitConfigDownload(cmd.Context(), tmpDir, data.policyConfiguration)
if err != nil {
allErrors = multierror.Append(allErrors, err)
return
}

Check warning on line 87 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L64-L87

Added lines #L64 - L87 were not covered by tests

// Changing data.policyConfiguration to the name of the newly downloaded file means we can
// use the code below to load the config
data.policyConfiguration = configFile

Check warning on line 91 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L91

Added line #L91 was not covered by tests
}

// Check if policyConfiguration is a file path. If so, try to read it. If successful we write
// that into the data.policyConfiguration var.
if utils.HasJsonOrYamlExt(data.policyConfiguration) {
fs := utils.FS(ctx)
policyBytes, err := afero.ReadFile(fs, data.policyConfiguration)
if err != nil {
allErrors = multierror.Append(allErrors, err)
return
}

Check warning on line 102 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L96-L102

Added lines #L96 - L102 were not covered by tests
// Check for empty file as that would cause a false "success"
if len(policyBytes) == 0 {
err := fmt.Errorf("file %s is empty", data.policyConfiguration)
allErrors = multierror.Append(allErrors, err)
return
}
log.Debugf("Loaded %s as policyConfiguration", data.policyConfiguration)
data.policyConfiguration = string(policyBytes)

Check warning on line 110 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L104-L110

Added lines #L104 - L110 were not covered by tests
}

if p, err := policy.NewInputPolicy(cmd.Context(), data.policyConfiguration, data.effectiveTime); err != nil {
allErrors = multierror.Append(allErrors, err)
} else {
data.policy = p
}
return

Check warning on line 118 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L113-L118

Added lines #L113 - L118 were not covered by tests
},
RunE: func(cmd *cobra.Command, args []string) error {

type result struct {
err error
input input.Input
data []evaluator.Data
policyInput []byte
}

ch := make(chan result, len(data.filePaths))

var lock sync.WaitGroup

for _, f := range data.filePaths {
lock.Add(1)
go func(fpath string) {
defer lock.Done()

ctx := cmd.Context()
out, err := validate(ctx, fpath, data.policy, data.namespaces, data.effectiveTime, false)
res := result{
err: err,
input: input.Input{
FilePath: fpath,
Success: err == nil,
},
}
// Skip on err to not panic. Error is return on routine completion.
if err == nil {
res.input.Violations = out.Violations()
showSuccesses, _ := cmd.Flags().GetBool("show-successes")
res.input.Warnings = out.Warnings()

successes := out.Successes()
res.input.SuccessCount = len(successes)
if showSuccesses {
res.input.Successes = successes
}
res.data = out.Data

Check warning on line 158 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L120-L158

Added lines #L120 - L158 were not covered by tests
}
res.input.Success = err == nil && len(res.input.Violations) == 0
ch <- res

Check warning on line 161 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L160-L161

Added lines #L160 - L161 were not covered by tests
}(f)
}

lock.Wait()
close(ch)

var inputs []input.Input
var manyData [][]evaluator.Data
var manyPolicyInput [][]byte
var allErrors error = nil

for r := range ch {
if r.err != nil {
e := fmt.Errorf("error validating file %s: %w", r.input.FilePath, r.err)
allErrors = multierror.Append(allErrors, e)
} else {
inputs = append(inputs, r.input)
manyData = append(manyData, r.data)
manyPolicyInput = append(manyPolicyInput, r.policyInput)
}

Check warning on line 181 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L165-L181

Added lines #L165 - L181 were not covered by tests
}
if allErrors != nil {
return allErrors
}

Check warning on line 185 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L183-L185

Added lines #L183 - L185 were not covered by tests

// Ensure some consistency in output.
sort.Slice(inputs, func(i, j int) bool {
return inputs[i].FilePath > inputs[j].FilePath
})

Check warning on line 190 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L188-L190

Added lines #L188 - L190 were not covered by tests

if len(data.outputFile) > 0 {
data.output = append(data.output, fmt.Sprintf("%s=%s", input.JSON, data.outputFile))
}

Check warning on line 194 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L192-L194

Added lines #L192 - L194 were not covered by tests

report, err := input.NewReport(inputs, data.policy, manyData, manyPolicyInput)
if err != nil {
return err
}

Check warning on line 199 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L196-L199

Added lines #L196 - L199 were not covered by tests

p := format.NewTargetParser(input.JSON, cmd.OutOrStdout(), utils.FS(cmd.Context()))
if err := report.WriteAll(data.output, p); err != nil {
return err
}

Check warning on line 204 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L201-L204

Added lines #L201 - L204 were not covered by tests

if data.strict && !report.Success {
// TODO: replace this with proper message and exit code 1.
return errors.New("success criteria not met")
}

Check warning on line 209 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L206-L209

Added lines #L206 - L209 were not covered by tests

return nil

Check warning on line 211 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L211

Added line #L211 was not covered by tests
},
}

cmd.Flags().StringSliceVarP(&data.filePaths, "file", "f", data.filePaths, "path to input YAML/JSON file (required)")

cmd.Flags().StringVarP(&data.policyConfiguration, "policy", "p", data.policyConfiguration, hd.Doc(`
Policy configuration as:
* Kubernetes reference ([<namespace>/]<name>)
* file (policy.yaml)
* git reference (github.com/user/repo//default?ref=main), or
* inline JSON ('{sources: {...}, configuration: {...}}')")`))

cmd.Flags().StringSliceVarP(&data.output, "output", "o", data.output, hd.Doc(`
write output to a file in a specific format, e.g. yaml=/tmp/output.yaml. Use empty string
path for stdout, e.g. yaml. May be used multiple times. Possible formats are json and yaml`))

cmd.Flags().BoolVarP(&data.strict, "strict", "s", data.strict,
"return non-zero status on non-successful validation")

cmd.Flags().StringVar(&data.effectiveTime, "effective-time", policy.Now, hd.Doc(`
Run policy checks with the provided time. Useful for testing rules with
effective dates in the future. The value can be "now" (default) - for
current time, "attestation" - for time from the youngest attestation, or
a RFC3339 formatted value, e.g. 2022-11-18T00:00:00Z.`))

cmd.Flags().BoolVar(&data.info, "info", data.info, hd.Doc(`
Include additional information on the failures. For instance for policy
violations, include the title and the description of the failed policy
rule.`))

cmd.Flags().StringSliceVar(&data.namespaces, "namespace", data.namespaces,
"the namespace containing the policy to run. May be used multiple times")

cmd.Flags().StringVar(&data.outputFile, "output-file", "", hd.Doc(`
write output to a file in a specific format, e.g. yaml=/tmp/output.yaml. Use empty string
path for stdout, e.g. yaml. May be used multiple times. Possible formats are json and yaml`))

if err := cmd.MarkFlagRequired("file"); err != nil {
panic(err)

Check warning on line 250 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L250

Added line #L250 was not covered by tests
}

if err := cmd.MarkFlagRequired("policy"); err != nil {
panic(err)

Check warning on line 254 in cmd/validate/input.go

View check run for this annotation

Codecov / codecov/patch

cmd/validate/input.go#L254

Added line #L254 was not covered by tests
}

return cmd
}
2 changes: 2 additions & 0 deletions cmd/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"github.com/enterprise-contract/ec-cli/internal/definition"
"github.com/enterprise-contract/ec-cli/internal/image"
"github.com/enterprise-contract/ec-cli/internal/input"
)

var ValidateCmd *cobra.Command
Expand All @@ -32,6 +33,7 @@ func init() {
func init() {
ValidateCmd.AddCommand(validateImageCmd(image.ValidateImage))
ValidateCmd.AddCommand(validateDefinitionCmd(definition.ValidateDefinition))
ValidateCmd.AddCommand(validateInputCmd(input.ValidateInput))
}

func NewValidateCmd() *cobra.Command {
Expand Down
Loading

0 comments on commit 6306bc1

Please sign in to comment.