From 96800d747bd6e35c37fc3ae700e00b16427d3055 Mon Sep 17 00:00:00 2001 From: Colin J Lacy Date: Wed, 26 Jun 2024 09:04:54 -0400 Subject: [PATCH] cmd/exec: adds --stdin-input (-I) flag for input piping or manual entry (#6822) Signed-off-by: Colin Lacy --- cmd/exec.go | 14 +- cmd/exec_test.go | 154 +++++++++++++++--- cmd/internal/exec/exec.go | 202 ++++++------------------ cmd/internal/exec/exec_test.go | 197 +++++++++++++++++++++++ cmd/internal/exec/json_reporter.go | 85 ++++++++++ cmd/internal/exec/json_reporter_test.go | 164 +++++++++++++++++++ cmd/internal/exec/params.go | 53 +++++++ cmd/internal/exec/params_test.go | 57 +++++++ cmd/internal/exec/parser.go | 23 +++ cmd/internal/exec/parser_test.go | 66 ++++++++ cmd/internal/exec/std_in_reader.go | 25 +++ cmd/internal/exec/std_in_reader_test.go | 53 +++++++ docs/content/cli.md | 1 + 13 files changed, 915 insertions(+), 179 deletions(-) create mode 100644 cmd/internal/exec/exec_test.go create mode 100644 cmd/internal/exec/json_reporter.go create mode 100644 cmd/internal/exec/json_reporter_test.go create mode 100644 cmd/internal/exec/params.go create mode 100644 cmd/internal/exec/params_test.go create mode 100644 cmd/internal/exec/parser.go create mode 100644 cmd/internal/exec/parser_test.go create mode 100644 cmd/internal/exec/std_in_reader.go create mode 100644 cmd/internal/exec/std_in_reader_test.go diff --git a/cmd/exec.go b/cmd/exec.go index a39f3030bf..c1e3de3af6 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -52,7 +53,6 @@ the OPA configuration) against each input file. This can be overridden by specifying the --decision argument and pointing at a specific policy decision, e.g., opa exec --decision /foo/bar/baz ...`, - Args: cobra.MinimumNArgs(1), PreRunE: func(cmd *cobra.Command, _ []string) error { return env.CmdFlags.CheckEnvironmentVariables(cmd) }, @@ -78,6 +78,7 @@ e.g., opa exec --decision /foo/bar/baz ...`, cmd.Flags().VarP(params.LogLevel, "log-level", "l", "set log level") cmd.Flags().Var(params.LogFormat, "log-format", "set log format") cmd.Flags().StringVar(¶ms.LogTimestampFormat, "log-timestamp-format", "", "set log timestamp format (OPA_LOG_TIMESTAMP_FORMAT environment variable)") + cmd.Flags().BoolVarP(¶ms.StdIn, "stdin-input", "I", false, "read input document from stdin rather than a static file") cmd.Flags().DurationVar(¶ms.Timeout, "timeout", 0, "set exec timeout with a Go-style duration, such as '5m 30s'. (default unlimited)") addV1CompatibleFlag(cmd.Flags(), ¶ms.V1Compatible, false) @@ -95,6 +96,10 @@ func runExec(params *exec.Params) error { } func runExecWithContext(ctx context.Context, params *exec.Params) error { + if minimumInputErr := validateMinimumInput(params); minimumInputErr != nil { + return minimumInputErr + } + stdLogger, consoleLogger, err := setupLogging(params.LogLevel.String(), params.LogFormat.String(), params.LogTimestampFormat) if err != nil { return fmt.Errorf("config error: %w", err) @@ -258,3 +263,10 @@ func injectExplicitBundles(root map[string]interface{}, paths []string) error { return nil } + +func validateMinimumInput(params *exec.Params) error { + if !params.StdIn && len(params.Paths) == 0 { + return errors.New("requires at least 1 path arg, or the --stdin-input flag") + } + return nil +} diff --git a/cmd/exec_test.go b/cmd/exec_test.go index cf56004bf5..aaa56922da 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -771,14 +771,14 @@ func TestFailFlagCases(t *testing.T) { "files/test.json": `{"foo": 7}`, "bundle/x.rego": `package fail.defined.flag - some_function { - input.foo == 7 - } - - default fail_test := false - fail_test { - some_function - }`, + some_function { + input.foo == 7 + } + + default fail_test := false + fail_test { + some_function + }`, }, decision: "fail/defined/flag/fail_test", expectError: true, @@ -853,14 +853,14 @@ func TestFailFlagCases(t *testing.T) { "files/test.json": `{"foo": 7}`, "bundle/x.rego": `package fail.defined.flag - some_function { - input.foo == 7 - } + some_function { + input.foo == 7 + } - default fail_test := false - fail_test { - some_function - }`, + default fail_test := false + fail_test { + some_function + }`, }, decision: "fail/defined/flag/fail_test", expectError: false, @@ -936,14 +936,14 @@ func TestFailFlagCases(t *testing.T) { "files/test.json": `{"foo": 7}`, "bundle/x.rego": `package fail.non.empty.flag - some_function { - input.foo == 7 - } + some_function { + input.foo == 7 + } - default fail_test := false - fail_test { - some_function - }`, + default fail_test := false + fail_test { + some_function + }`, }, decision: "fail/non/empty/flag/fail_test", expectError: true, @@ -1045,6 +1045,116 @@ func TestFailFlagCases(t *testing.T) { } } +func TestExecWithInvalidInputOptions(t *testing.T) { + tests := []struct { + description string + files map[string]string + stdIn bool + input string + expectError bool + expected string + }{ + { + description: "path passed in as arg should not raise error", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + + test_fun := x { + x = false + x + } + + undefined_test { + test_fun + }`, + }, + expectError: false, + expected: "", + }, + { + description: "no paths passed in as args should raise error if --stdin-input flag not set", + files: map[string]string{ + "bundle/x.rego": `package system + + test_fun := x { + x = false + x + } + + undefined_test { + test_fun + }`, + }, + expectError: true, + expected: "requires at least 1 path arg, or the --stdin-input flag", + }, + { + description: "should not raise error if --stdin-input flag is set when no paths passed in as args", + files: map[string]string{ + "bundle/x.rego": `package system + + test_fun := x { + x = false + x + } + + undefined_test { + test_fun + }`, + }, + stdIn: true, + input: `{"foo": 7}`, + expectError: false, + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + test.WithTempFS(tt.files, func(dir string) { + var buf bytes.Buffer + params := exec.NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.BundlePaths = []string{dir + "/bundle/"} + if tt.stdIn { + params.StdIn = true + tempFile, err := os.CreateTemp("", "test") + if err != nil { + t.Fatalf("unexpected error creating temp file: %q", err.Error()) + } + if _, err := tempFile.Write([]byte(tt.input)); err != nil { + t.Fatalf("unexpeced error when writing to temp file: %q", err.Error()) + } + if _, err := tempFile.Seek(0, 0); err != nil { + t.Fatalf("unexpected error when rewinding temp file: %q", err.Error()) + } + oldStdin := os.Stdin + defer func() { + os.Stdin = oldStdin + os.Remove(tempFile.Name()) + }() + os.Stdin = tempFile + } else { + if _, ok := tt.files["files/test.json"]; ok { + params.Paths = append(params.Paths, dir+"/files/") + } + } + + err := runExec(params) + if err != nil && !tt.expectError { + t.Fatalf("unexpected error in test: %q", err.Error()) + } + if err == nil && tt.expectError { + t.Fatalf("expected error %q, but none occurred in test", tt.expected) + } + if err != nil && err.Error() != tt.expected { + t.Fatalf("expected error %q, but got %q", tt.expected, err.Error()) + } + }) + }) + } +} + func TestExecTimeoutWithMalformedRemoteBundle(t *testing.T) { test.WithTempFS(map[string]string{}, func(dir string) { // Note(philipc): We add the "raw bundles" flag so that we can stuff a diff --git a/cmd/internal/exec/exec.go b/cmd/internal/exec/exec.go index edd1d88c4b..ed8f940fe1 100644 --- a/cmd/internal/exec/exec.go +++ b/cmd/internal/exec/exec.go @@ -2,61 +2,25 @@ package exec import ( "context" - "encoding/json" "errors" - "fmt" - "io" "os" "path" "path/filepath" - "time" + "strings" - "github.com/open-policy-agent/opa/logging" "github.com/open-policy-agent/opa/sdk" - "github.com/open-policy-agent/opa/util" ) -type Params struct { - Paths []string // file paths to execute against - Output io.Writer // output stream to write normal output to - ConfigFile string // OPA configuration file path - ConfigOverrides []string // OPA configuration overrides (--set arguments) - ConfigOverrideFiles []string // OPA configuration overrides (--set-file arguments) - OutputFormat *util.EnumFlag // output format (default: pretty) - LogLevel *util.EnumFlag // log level for plugins - LogFormat *util.EnumFlag // log format for plugins - LogTimestampFormat string // log timestamp format for plugins - BundlePaths []string // explicit paths of bundles to inject into the configuration - Decision string // decision to evaluate (overrides default decision set by configuration) - Fail bool // exits with non-zero exit code on undefined policy decision or empty policy decision result or other errors - FailDefined bool // exits with non-zero exit code on 'not undefined policy decisiondefined' or 'not empty policy decision result' or other errors - FailNonEmpty bool // exits with non-zero exit code on non-empty set (array) results - Timeout time.Duration // timeout to prevent infinite hangs. If set to 0, the command will never time out - V1Compatible bool // use OPA 1.0 compatibility mode - Logger logging.Logger // Logger override. If set to nil, the default logger is used. -} - -func NewParams(w io.Writer) *Params { - return &Params{ - Output: w, - OutputFormat: util.NewEnumFlag("pretty", []string{"pretty", "json"}), - LogLevel: util.NewEnumFlag("error", []string{"debug", "info", "error"}), - LogFormat: util.NewEnumFlag("json", []string{"text", "json", "json-pretty"}), +var ( + r *jsonReporter + parsers = map[string]parser{ + ".json": utilParser{}, + ".yaml": utilParser{}, + ".yml": utilParser{}, } -} +) -func (p *Params) validateParams() error { - if p.Fail && p.FailDefined { - return errors.New("specify --fail or --fail-defined but not both") - } - if p.FailNonEmpty && p.Fail { - return errors.New("specify --fail-non-empty or --fail but not both") - } - if p.FailNonEmpty && p.FailDefined { - return errors.New("specify --fail-non-empty or --fail-defined but not both") - } - return nil -} +const stdInPath = "--stdin-input" // Exec executes OPA against the supplied files and outputs each result. // @@ -66,18 +30,44 @@ func (p *Params) validateParams() error { // - exit codes set by convention or policy (e.g,. non-empty set => error) // - support for new input file formats beyond JSON and YAML func Exec(ctx context.Context, opa *sdk.OPA, params *Params) error { + if err := params.validateParams(); err != nil { + return err + } + + r = &jsonReporter{w: params.Output, buf: make([]result, 0), ctx: &ctx, opa: opa, params: params, decisionFunc: opa.Decision} - err := params.validateParams() + var err error + if params.StdIn { + err = execOnStdIn() + } else { + err = execOnInputFiles(params) + } if err != nil { return err } + return r.ReportFailure() +} - now := time.Now() - r := &jsonReporter{w: params.Output, buf: make([]result, 0)} +func execOnStdIn() error { + sr := stdInReader{Reader: os.Stdin} + p := utilParser{} + raw := sr.ReadInput() + input, err := p.Parse(strings.NewReader(raw)) + if err != nil { + return err + } else if input == nil { + return errors.New("cannot execute on empty input; please enter valid json or yaml when using the --stdin-input flag") + } + r.StoreDecision(&input, stdInPath) + return r.Close() +} - failCount := 0 - errorCount := 0 +type fileListItem struct { + Path string + Error error +} +func execOnInputFiles(params *Params) error { for item := range listAllPaths(params.Paths) { if item.Error != nil { @@ -87,94 +77,17 @@ func Exec(ctx context.Context, opa *sdk.OPA, params *Params) error { input, err := parse(item.Path) if err != nil { - if err2 := r.Report(result{Path: item.Path, Error: err}); err2 != nil { - return err2 - } + r.Report(result{Path: item.Path, Error: err}) if params.FailDefined || params.Fail || params.FailNonEmpty { - errorCount++ + r.errorCount++ } continue } else if input == nil { continue } - - rs, err := opa.Decision(ctx, sdk.DecisionOptions{ - Path: params.Decision, - Now: now, - Input: input, - }) - if err != nil { - if err2 := r.Report(result{Path: item.Path, Error: err}); err2 != nil { - return err2 - } - if (params.FailDefined && !sdk.IsUndefinedErr(err)) || (params.Fail && sdk.IsUndefinedErr(err)) || (params.FailNonEmpty && !sdk.IsUndefinedErr(err)) { - errorCount++ - } - continue - } - - if err := r.Report(result{Path: item.Path, Result: &rs.Result}); err != nil { - return err - } - - if (params.FailDefined && rs.Result != nil) || (params.Fail && rs.Result == nil) { - failCount++ - } - - if params.FailNonEmpty && rs.Result != nil { - // Check if rs.Result is an array and has one or more members - resultArray, isArray := rs.Result.([]interface{}) - if (!isArray) || (isArray && (len(resultArray) > 0)) { - failCount++ - } - } - } - if err := r.Close(); err != nil { - return err + r.StoreDecision(input, item.Path) } - - if (params.Fail || params.FailDefined || params.FailNonEmpty) && (failCount > 0 || errorCount > 0) { - if params.Fail { - return fmt.Errorf("there were %d failures and %d errors counted in the results list, and --fail is set", failCount, errorCount) - } - if params.FailDefined { - return fmt.Errorf("there were %d failures and %d errors counted in the results list, and --fail-defined is set", failCount, errorCount) - } - return fmt.Errorf("there were %d failures and %d errors counted in the results list, and --fail-non-empty is set", failCount, errorCount) - } - - return nil -} - -type result struct { - Path string `json:"path"` - Error error `json:"error,omitempty"` - Result *interface{} `json:"result,omitempty"` -} - -type jsonReporter struct { - w io.Writer - buf []result -} - -func (jr *jsonReporter) Report(r result) error { - jr.buf = append(jr.buf, r) - return nil -} - -func (jr *jsonReporter) Close() error { - enc := json.NewEncoder(jr.w) - enc.SetIndent("", " ") - return enc.Encode(struct { - Result []result `json:"result"` - }{ - Result: jr.buf, - }) -} - -type fileListItem struct { - Path string - Error error + return r.Close() } func listAllPaths(roots []string) chan fileListItem { @@ -200,31 +113,8 @@ func listAllPaths(roots []string) chan fileListItem { return ch } -var parsers = map[string]parser{ - ".json": utilParser{}, - ".yaml": utilParser{}, - ".yml": utilParser{}, -} - -type parser interface { - Parse(io.Reader) (interface{}, error) -} - -type utilParser struct { -} - -func (utilParser) Parse(r io.Reader) (interface{}, error) { - bs, err := io.ReadAll(r) - if err != nil { - return nil, err - } - var x interface{} - return x, util.Unmarshal(bs, &x) -} - func parse(p string) (*interface{}, error) { - - parser, ok := parsers[path.Ext(p)] + selectedParser, ok := parsers[path.Ext(p)] if !ok { return nil, nil } @@ -236,7 +126,7 @@ func parse(p string) (*interface{}, error) { defer f.Close() - val, err := parser.Parse(f) + val, err := selectedParser.Parse(f) if err != nil { return nil, err } diff --git a/cmd/internal/exec/exec_test.go b/cmd/internal/exec/exec_test.go new file mode 100644 index 0000000000..08f6f85242 --- /dev/null +++ b/cmd/internal/exec/exec_test.go @@ -0,0 +1,197 @@ +package exec + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-policy-agent/opa/logging" + "github.com/open-policy-agent/opa/sdk" + + "github.com/open-policy-agent/opa/util/test" +) + +/* +Most of cmd/internal/exec/exec.go is tested indirectly in cmd/exec_test.go. +This file tests internal functions that are not as easily accessed, particularly +their edge and error cases. +*/ + +func TestParse(t *testing.T) { + files := map[string]string{ + "valid.json": `{"this": "that"}`, + "invalid.json": `{[a",!`, + } + + test.WithTempFS(files, func(rootDir string) { + if val, err := parse(filepath.Join(rootDir, "no parser")); val != nil || err != nil { + t.Fatalf("return values should have been nil passing a file path with no matching extension, got val: %v; err: %s", val, err.Error()) + } + if _, err := parse(filepath.Join(rootDir, "nonexistent.json")); err == nil { + t.Fatalf("should have received an error for passing a file path that does not exist") + } + if _, err := parse(filepath.Join(rootDir, "invalid.json")); err == nil { + t.Fatalf("should have received an error for passing a file with invalid json") + } + if val, err := parse(filepath.Join(rootDir, "valid.json")); err != nil { + t.Fatalf("unexpected error when passing file wiith valid json: %q", err.Error()) + } else { + v := *val + that, ok := v.(map[string]interface{})["this"] + if !ok { + t.Fatalf("expected parsed data to have key %q with value %q, found none", "this", "that") + } + if that.(string) != "that" { + t.Fatalf("expected parsed data to have key %q with value %q, instead got value %v", "this", "that", that) + } + } + }) +} + +func TestListAllPaths(t *testing.T) { + files := map[string]string{ + "file.json": `{"this": "that"}`, + } + + test.WithTempFS(files, func(rootDir string) { + notFound := "./test/error" + ch := listAllPaths([]string{rootDir, notFound}) + for item := range ch { + if strings.Contains(item.Path, rootDir) { + if item.Error != nil { + t.Errorf("unexpected error for mock file: %q", item.Error) + } + } else if strings.Contains(item.Path, notFound) { + if item.Error == nil { + t.Errorf("expected error for tempDir, found none") + } + } + } + }) +} + +func TestExec(t *testing.T) { + tests := []struct { + description string + files map[string]string + stdIn bool + input string + assertion func(err error) + }{ + { + description: "should read from valid JSON file and not raise an error", + files: map[string]string{ + "files/test.json": `{"foo": 7}`, + "bundle/x.rego": `package system + + test_fun := x { + x = false + x + } + + undefined_test { + test_fun + }`, + }, + assertion: func(err error) { + if err != nil { + t.Fatalf("unexpected error raised: %q", err.Error()) + } + }, + }, + { + description: "should raise error count if invalid json is found", + files: map[string]string{ + "files/test.json": `{[foo":`, + "bundle/x.rego": `package system + + test_fun := x { + x = false + x + } + + undefined_test { + test_fun + }`, + }, + assertion: func(err error) { + if err == nil { + t.Fatalf("expected error, found none") + } + if r.errorCount != 1 { + t.Fatalf("expected r.errorCount to be 1, got %d", r.errorCount) + } + }, + }, + { + description: "should read from stdin-input if flag is set", + files: map[string]string{ + "bundle/x.rego": `package system + + test_fun := x { + x = false + x + } + + undefined_test { + test_fun + }`, + }, + stdIn: true, + input: `{"foo": 7}`, + assertion: func(err error) { + if err != nil { + t.Fatalf("unexpected error raised: %q", err.Error()) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + test.WithTempFS(tt.files, func(dir string) { + var buf bytes.Buffer + params := NewParams(&buf) + _ = params.OutputFormat.Set("json") + params.BundlePaths = []string{dir + "/bundle/"} + params.FailNonEmpty = true + if tt.stdIn { + params.StdIn = true + tempFile, err := os.CreateTemp("", "test") + if err != nil { + t.Fatalf("unexpected error creating temp file: %q", err.Error()) + } + if _, err := tempFile.Write([]byte(tt.input)); err != nil { + t.Fatalf("unexpeced error when writing to temp file: %q", err.Error()) + } + if _, err := tempFile.Seek(0, 0); err != nil { + t.Fatalf("unexpected error when rewinding temp file: %q", err.Error()) + } + oldStdin := os.Stdin + defer func() { + os.Stdin = oldStdin + os.Remove(tempFile.Name()) + }() + os.Stdin = tempFile + } else { + if _, ok := tt.files["files/test.json"]; ok { + params.Paths = append(params.Paths, dir+"/files/") + } + } + ctx := context.Background() + opa, _ := sdk.New(ctx, sdk.Options{ + Config: bytes.NewReader([]byte{}), + Logger: logging.NewNoOpLogger(), + ConsoleLogger: logging.NewNoOpLogger(), + Ready: make(chan struct{}), + V1Compatible: params.V1Compatible, + }) + + err := Exec(ctx, opa, params) + tt.assertion(err) + }) + }) + } +} diff --git a/cmd/internal/exec/json_reporter.go b/cmd/internal/exec/json_reporter.go new file mode 100644 index 0000000000..c0b23dbe7f --- /dev/null +++ b/cmd/internal/exec/json_reporter.go @@ -0,0 +1,85 @@ +package exec + +import ( + "context" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/open-policy-agent/opa/sdk" +) + +type result struct { + Path string `json:"path"` + Error error `json:"error,omitempty"` + Result *interface{} `json:"result,omitempty"` +} + +type jsonReporter struct { + w io.Writer + buf []result + ctx *context.Context + opa *sdk.OPA + params *Params + errorCount int + failCount int + decisionFunc func(ctx context.Context, options sdk.DecisionOptions) (*sdk.DecisionResult, error) +} + +func (jr *jsonReporter) Report(r result) { + jr.buf = append(jr.buf, r) +} + +func (jr *jsonReporter) Close() error { + enc := json.NewEncoder(jr.w) + enc.SetIndent("", " ") + return enc.Encode(struct { + Result []result `json:"result"` + }{ + Result: jr.buf, + }) +} + +func (jr *jsonReporter) StoreDecision(input *interface{}, itemPath string) { + rs, err := jr.decisionFunc(*jr.ctx, sdk.DecisionOptions{ + Path: jr.params.Decision, + Now: time.Now(), + Input: input, + }) + if err != nil { + jr.Report(result{Path: itemPath, Error: err}) + if (jr.params.FailDefined && !sdk.IsUndefinedErr(err)) || (jr.params.Fail && sdk.IsUndefinedErr(err)) || (jr.params.FailNonEmpty && !sdk.IsUndefinedErr(err)) { + jr.errorCount++ + } + return + } + + jr.Report(result{Path: itemPath, Result: &rs.Result}) + + if (jr.params.FailDefined && rs.Result != nil) || (jr.params.Fail && rs.Result == nil) { + jr.failCount++ + } + + if jr.params.FailNonEmpty && rs.Result != nil { + // Check if rs.Result is an array and has one or more members + resultArray, isArray := rs.Result.([]interface{}) + if (!isArray) || (isArray && (len(resultArray) > 0)) { + jr.failCount++ + } + } +} + +func (jr *jsonReporter) ReportFailure() error { + if (jr.params.Fail || jr.params.FailDefined || jr.params.FailNonEmpty) && (jr.failCount > 0 || jr.errorCount > 0) { + if jr.params.Fail { + return fmt.Errorf("there were %d failures and %d errors counted in the results list, and --fail is set", jr.failCount, jr.errorCount) + } + if jr.params.FailDefined { + return fmt.Errorf("there were %d failures and %d errors counted in the results list, and --fail-defined is set", jr.failCount, jr.errorCount) + } + return fmt.Errorf("there were %d failures and %d errors counted in the results list, and --fail-non-empty is set", jr.failCount, jr.errorCount) + } + + return nil +} diff --git a/cmd/internal/exec/json_reporter_test.go b/cmd/internal/exec/json_reporter_test.go new file mode 100644 index 0000000000..13104864c4 --- /dev/null +++ b/cmd/internal/exec/json_reporter_test.go @@ -0,0 +1,164 @@ +package exec + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/open-policy-agent/opa/sdk" +) + +func TestJsonReporter_Close(t *testing.T) { + wr := bytes.NewBuffer([]byte{}) + wrp := &wr + testString := "test" + testData := []result{ + {Path: testString}, + } + jr := jsonReporter{w: *wrp, buf: testData} + if err := jr.Close(); err != nil { + t.Fatalf("unexpected error running jsonReporter.Close: %q", err.Error()) + } + results := struct { + Result []result + }{} + if err := json.Unmarshal(wr.Bytes(), &results); err != nil { + t.Fatalf("unexpected error deserializing results: %q", err.Error()) + } + if results.Result[0].Path != testString { + t.Fatalf("expected result Path to be %q, got %q", testString, results.Result[0].Path) + } +} + +func TestJsonReporter_StoreDecision(t *testing.T) { + testString := "test" + ctx := context.TODO() + tcs := []struct { + Name string + Path string + DecisionFunc func(ctx context.Context, options sdk.DecisionOptions) (*sdk.DecisionResult, error) + Params Params + ExpectedErrorCount int + ExpectedFailureCount int + }{ + { + Name: "should return nil with increased error count if error is raised from decision", + Path: testString, + DecisionFunc: func(_ context.Context, _ sdk.DecisionOptions) (*sdk.DecisionResult, error) { + return nil, fmt.Errorf("test") + }, + Params: Params{FailNonEmpty: true}, + ExpectedErrorCount: 1, + ExpectedFailureCount: 0, + }, + { + Name: "should increase failure count if decision result is nil and params.Fail is true", + Path: testString, + DecisionFunc: func(_ context.Context, _ sdk.DecisionOptions) (*sdk.DecisionResult, error) { + return &sdk.DecisionResult{Result: nil}, nil + }, + Params: Params{Fail: true}, + ExpectedErrorCount: 0, + ExpectedFailureCount: 1, + }, + { + Name: "should increase failure count by 2 if decision result is not nil and params.FailDefined and params.FailNonEmpty are true", + Path: testString, + DecisionFunc: func(_ context.Context, _ sdk.DecisionOptions) (*sdk.DecisionResult, error) { + return &sdk.DecisionResult{Result: []string{testString}}, nil + }, + Params: Params{FailDefined: true, FailNonEmpty: true}, + ExpectedErrorCount: 0, + ExpectedFailureCount: 2, + }, + } + + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + wr := bytes.NewBuffer([]byte{}) + j := jsonReporter{ + w: wr, + buf: []result{}, + decisionFunc: tc.DecisionFunc, + params: &tc.Params, + ctx: &ctx, + } + j.StoreDecision(nil, testString) + if j.errorCount != tc.ExpectedErrorCount { + t.Fatalf("expected error count to be %d, got %d", tc.ExpectedErrorCount, j.errorCount) + } + if j.failCount != tc.ExpectedFailureCount { + t.Fatalf("expected failure count to be %d, got %d", tc.ExpectedFailureCount, j.failCount) + } + }) + } +} + +func TestJsonReporter_ReportFailure(t *testing.T) { + tcs := []struct { + Name string + Params Params + Errs int + Fails int + IsErr bool + }{ + { + Name: "errors with Fail flagged", + Params: Params{Fail: true}, + Errs: 5, + IsErr: true, + }, + { + Name: "failures with FailDefined flagged", + Params: Params{FailDefined: true}, + Fails: 3, + IsErr: true, + }, + { + Name: "failures and errors with FailNonEmpty flagged", + Params: Params{FailNonEmpty: true}, + Fails: 1, + Errs: 1, + IsErr: true, + }, + { + Name: "no failures nor errors", + Params: Params{Fail: true}, + Fails: 0, + Errs: 0, + IsErr: false, + }, + { + Name: "failures and errors without param flags", + Params: Params{}, + Fails: 2, + Errs: 2, + IsErr: false, + }, + } + + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + wr := bytes.NewBuffer([]byte{}) + ctx := context.Background() + j := jsonReporter{ + w: wr, + buf: []result{}, + decisionFunc: func(_ context.Context, _ sdk.DecisionOptions) (*sdk.DecisionResult, error) { + return &sdk.DecisionResult{}, nil + }, + params: &tc.Params, + ctx: &ctx, + } + j.errorCount = tc.Errs + j.failCount = tc.Fails + if err := j.ReportFailure(); tc.IsErr && err == nil { + t.Fatalf("expected error, found none") + } else if !tc.IsErr && err != nil { + t.Fatalf("unexpected error: %q", err.Error()) + } + }) + } +} diff --git a/cmd/internal/exec/params.go b/cmd/internal/exec/params.go new file mode 100644 index 0000000000..bf9c0a1394 --- /dev/null +++ b/cmd/internal/exec/params.go @@ -0,0 +1,53 @@ +package exec + +import ( + "errors" + "io" + "time" + + "github.com/open-policy-agent/opa/logging" + "github.com/open-policy-agent/opa/util" +) + +type Params struct { + Paths []string // file paths to execute against + Output io.Writer // output stream to write normal output to + ConfigFile string // OPA configuration file path + ConfigOverrides []string // OPA configuration overrides (--set arguments) + ConfigOverrideFiles []string // OPA configuration overrides (--set-file arguments) + OutputFormat *util.EnumFlag // output format (default: pretty) + LogLevel *util.EnumFlag // log level for plugins + LogFormat *util.EnumFlag // log format for plugins + LogTimestampFormat string // log timestamp format for plugins + BundlePaths []string // explicit paths of bundles to inject into the configuration + Decision string // decision to evaluate (overrides default decision set by configuration) + Fail bool // exits with non-zero exit code on undefined policy decision or empty policy decision result or other errors + FailDefined bool // exits with non-zero exit code on 'not undefined policy decisiondefined' or 'not empty policy decision result' or other errors + FailNonEmpty bool // exits with non-zero exit code on non-empty set (array) results + StdIn bool // pull input from std-in, rather than input files + Timeout time.Duration // timeout to prevent infinite hangs. If set to 0, the command will never time out + V1Compatible bool // use OPA 1.0 compatibility mode + Logger logging.Logger // Logger override. If set to nil, the default logger is used. +} + +func NewParams(w io.Writer) *Params { + return &Params{ + Output: w, + OutputFormat: util.NewEnumFlag("pretty", []string{"pretty", "json"}), + LogLevel: util.NewEnumFlag("error", []string{"debug", "info", "error"}), + LogFormat: util.NewEnumFlag("json", []string{"text", "json", "json-pretty"}), + } +} + +func (p *Params) validateParams() error { + if p.Fail && p.FailDefined { + return errors.New("specify --fail or --fail-defined but not both") + } + if p.FailNonEmpty && p.Fail { + return errors.New("specify --fail-non-empty or --fail but not both") + } + if p.FailNonEmpty && p.FailDefined { + return errors.New("specify --fail-non-empty or --fail-defined but not both") + } + return nil +} diff --git a/cmd/internal/exec/params_test.go b/cmd/internal/exec/params_test.go new file mode 100644 index 0000000000..fb6eb37d26 --- /dev/null +++ b/cmd/internal/exec/params_test.go @@ -0,0 +1,57 @@ +package exec + +import ( + "bytes" + "testing" +) + +func TestNewParams(t *testing.T) { + testString := "test" + w := bytes.NewBuffer([]byte{}) + p := NewParams(w) + if _, err := p.Output.Write([]byte(testString)); err != nil { + t.Fatalf("unexpected error writing to params.Output: %q", err) + } + if w.String() != testString { + t.Fatalf("expected params.Output bytes to be %q, got %q", testString, w.String()) + } +} + +func TestParams_validateParams(t *testing.T) { + tcs := []struct { + Name string + Params Params + ShouldError bool + }{ + { + Name: "should return error if p.Fail and p.FailDefined are true", + Params: Params{Fail: true, FailDefined: true}, + ShouldError: true, + }, + { + Name: "should return error if p.FailNonEmpty and p.Fail are true", + Params: Params{Fail: true, FailNonEmpty: true}, + ShouldError: true, + }, + { + Name: "should return error if .FailNonEmpty and p.FailDefined are true", + Params: Params{FailNonEmpty: true, FailDefined: true}, + ShouldError: true, + }, + { + Name: "should not return an error", + Params: Params{Fail: true, FailDefined: false, FailNonEmpty: false}, + ShouldError: false, + }, + } + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + err := tc.Params.validateParams() + if tc.ShouldError && err == nil { + t.Fatalf("expected error, saw none") + } else if !tc.ShouldError && err != nil { + t.Fatalf("unexpected error: %q", err.Error()) + } + }) + } +} diff --git a/cmd/internal/exec/parser.go b/cmd/internal/exec/parser.go new file mode 100644 index 0000000000..46ebf08dcf --- /dev/null +++ b/cmd/internal/exec/parser.go @@ -0,0 +1,23 @@ +package exec + +import ( + "io" + + "github.com/open-policy-agent/opa/util" +) + +type parser interface { + Parse(io.Reader) (interface{}, error) +} + +type utilParser struct { +} + +func (utilParser) Parse(r io.Reader) (interface{}, error) { + bs, err := io.ReadAll(r) + if err != nil { + return nil, err + } + var x interface{} + return x, util.Unmarshal(bs, &x) +} diff --git a/cmd/internal/exec/parser_test.go b/cmd/internal/exec/parser_test.go new file mode 100644 index 0000000000..6b7671226a --- /dev/null +++ b/cmd/internal/exec/parser_test.go @@ -0,0 +1,66 @@ +package exec + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" +) + +type errReader int + +func (errReader) Read(_ []byte) (n int, err error) { + return 0, errors.New("test error") +} + +func TestUtilParser_Parse(t *testing.T) { + testJSON := map[string]string{"this": "that"} + b, err := json.Marshal(testJSON) + if err != nil { + t.Fatalf("unexpected error marshalling valid json: %q", err.Error()) + } + tcs := []struct { + Name string + Reader io.Reader + ShouldError bool + Expectation func(x interface{}) + }{ + { + Name: "should return an error if the provided reader raises an error", + Reader: new(errReader), + ShouldError: true, + }, + { + Name: "should return an error for invalid JSON", + Reader: strings.NewReader("{[invalid json"), + ShouldError: true, + }, + { + Name: "should return a valid JSON object", + Reader: bytes.NewBuffer(b), + ShouldError: false, + Expectation: func(x interface{}) { + if val, ok := x.(map[string]interface{})["this"]; !ok { + t.Fatalf("expected returned value to have key %q, but none was found", "this") + } else if val != "that" { + t.Fatalf("expected returned value to have value %q for key %q, instead got %q", "that", "this", val) + } + }, + }, + } + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + up := utilParser{} + res, err := up.Parse(tc.Reader) + if tc.ShouldError { + if err == nil { + t.Fatalf("expected error, found none") + } + } else { + tc.Expectation(res) + } + }) + } +} diff --git a/cmd/internal/exec/std_in_reader.go b/cmd/internal/exec/std_in_reader.go new file mode 100644 index 0000000000..85db3ee1c7 --- /dev/null +++ b/cmd/internal/exec/std_in_reader.go @@ -0,0 +1,25 @@ +package exec + +import ( + "bufio" + "io" + "strings" +) + +type stdInReader struct { + Reader io.Reader +} + +func (sr *stdInReader) ReadInput() string { + var lines []string + in := bufio.NewScanner(sr.Reader) + for { + in.Scan() + line := in.Text() + if len(line) == 0 { + break + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} diff --git a/cmd/internal/exec/std_in_reader_test.go b/cmd/internal/exec/std_in_reader_test.go new file mode 100644 index 0000000000..87579e3135 --- /dev/null +++ b/cmd/internal/exec/std_in_reader_test.go @@ -0,0 +1,53 @@ +package exec + +import ( + "io" + "strings" + "testing" +) + +func TestStdInReader_ReadInput(t *testing.T) { + tcs := []struct { + Name string + Reader io.Reader + ExpectedRes string + }{ + { + Name: "should read multi-line json", + Reader: strings.NewReader(`{ +"this": "that", +"those": "them" +}`), + ExpectedRes: `{ +"this": "that", +"those": "them" +}`, + }, + { + Name: "should read multi-line yaml", + Reader: strings.NewReader(`this: that +those: +- them +- there`), + ExpectedRes: `this: that +those: +- them +- there`, + }, + { + Name: "should read single-line text", + Reader: strings.NewReader("test"), + ExpectedRes: "test", + }, + } + + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + sr := stdInReader{Reader: tc.Reader} + res := sr.ReadInput() + if res != tc.ExpectedRes { + t.Errorf("expected read result to be %q, got %q", tc.ExpectedRes, res) + } + }) + } +} diff --git a/docs/content/cli.md b/docs/content/cli.md index 18008b7765..c8ab00fa14 100755 --- a/docs/content/cli.md +++ b/docs/content/cli.md @@ -619,6 +619,7 @@ opa exec [ [...]] [flags] --log-timestamp-format string set log timestamp format (OPA_LOG_TIMESTAMP_FORMAT environment variable) --set stringArray override config values on the command line (use commas to specify multiple values) --set-file stringArray override config values with files on the command line (use commas to specify multiple values) + -I, --stdin-input read input document from `os.Stdin` rather than a static file --timeout duration set exec timeout with a Go-style duration, such as '5m 30s'. (default unlimited) --v1-compatible opt-in to OPA features and behaviors that will be enabled by default in a future OPA v1.0 release ```