mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-19 15:01:55 -06:00
e6727cbdf2
This adds in a new CLI sub command `opa bench` which will load and evaluate similar to `opa eval` but will perform benchmark testing of the query. There is also a new `--bench` option for `opa test` which will similarly perform benchmarking, except on the unit tests. Both use the golang testing frameworks benchmark tooling, and the output format is compliant with the go benchmark standard when using the newly added `gobench` output format option. They both support specifying a `--count` to run the benchmark a number of times and a `--benchmem` option to report memory statistics. To help enable using the `opa test` command better with the benchmark option there is now a `--run`/`-r` option that can be provided to specify a regex for what test cases should be run. The regex supports anything that is supported by re2: https://github.com/google/re2/wiki/Syntax These changes required updating to Go 1.13 to get the ability to report custom metrics with the benchmark results https://golang.org/pkg/testing/#B.ReportMetric To get Netlify on board we needed to add a `.go-version` file to the root of the repo. This is now the single source of truth for the OPA golang version. Fixes: #1424 Signed-off-by: Patrick East <east.patrick@gmail.com>
553 lines
14 KiB
Go
553 lines
14 KiB
Go
// Copyright 2018 The OPA Authors. All rights reserved.
|
|
// Use of this source code is governed by an Apache2
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/open-policy-agent/opa/ast"
|
|
"github.com/open-policy-agent/opa/cover"
|
|
fileurl "github.com/open-policy-agent/opa/internal/file/url"
|
|
pr "github.com/open-policy-agent/opa/internal/presentation"
|
|
"github.com/open-policy-agent/opa/internal/runtime"
|
|
"github.com/open-policy-agent/opa/metrics"
|
|
"github.com/open-policy-agent/opa/profiler"
|
|
"github.com/open-policy-agent/opa/rego"
|
|
"github.com/open-policy-agent/opa/topdown"
|
|
"github.com/open-policy-agent/opa/topdown/lineage"
|
|
"github.com/open-policy-agent/opa/util"
|
|
)
|
|
|
|
type evalCommandParams struct {
|
|
coverage bool
|
|
partial bool
|
|
unknowns []string
|
|
disableInlining []string
|
|
disableIndexing bool
|
|
dataPaths repeatedStringFlag
|
|
inputPath string
|
|
imports repeatedStringFlag
|
|
pkg string
|
|
stdin bool
|
|
stdinInput bool
|
|
explain *util.EnumFlag
|
|
metrics bool
|
|
instrument bool
|
|
ignore []string
|
|
outputFormat *util.EnumFlag
|
|
profile bool
|
|
profileTopResults bool
|
|
profileCriteria repeatedStringFlag
|
|
profileLimit intFlag
|
|
prettyLimit intFlag
|
|
fail bool
|
|
failDefined bool
|
|
bundlePaths repeatedStringFlag
|
|
}
|
|
|
|
func newEvalCommandParams() evalCommandParams {
|
|
return evalCommandParams{
|
|
outputFormat: util.NewEnumFlag(evalJSONOutput, []string{
|
|
evalJSONOutput,
|
|
evalValuesOutput,
|
|
evalBindingsOutput,
|
|
evalPrettyOutput,
|
|
evalSourceOutput,
|
|
}),
|
|
explain: newExplainFlag([]string{explainModeOff, explainModeFull, explainModeNotes, explainModeFails}),
|
|
}
|
|
}
|
|
|
|
func validateEvalParams(p *evalCommandParams, cmdArgs []string) error {
|
|
if len(cmdArgs) > 0 && p.stdin {
|
|
return errors.New("specify query argument or --stdin but not both")
|
|
} else if len(cmdArgs) == 0 && !p.stdin {
|
|
return errors.New("specify query argument or --stdin")
|
|
} else if len(cmdArgs) > 1 {
|
|
return errors.New("specify at most one query argument")
|
|
}
|
|
if p.stdin && p.stdinInput {
|
|
return errors.New("specify --stdin or --stdin-input but not both")
|
|
}
|
|
if p.stdinInput && p.inputPath != "" {
|
|
return errors.New("specify --stdin-input or --input but not both")
|
|
}
|
|
if p.fail && p.failDefined {
|
|
return errors.New("specify --fail or --fail-defined but not both")
|
|
}
|
|
of := p.outputFormat.String()
|
|
if p.partial && of != evalPrettyOutput && of != evalJSONOutput && of != evalSourceOutput {
|
|
return errors.New("invalid output format for partial evaluation")
|
|
} else if !p.partial && of == evalSourceOutput {
|
|
return errors.New("invalid output format for evaluation")
|
|
}
|
|
if p.profileLimit.isFlagSet() || p.profileCriteria.isFlagSet() {
|
|
p.profile = true
|
|
}
|
|
if p.profile {
|
|
p.metrics = true
|
|
}
|
|
if p.instrument {
|
|
p.metrics = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
evalJSONOutput = "json"
|
|
evalValuesOutput = "values"
|
|
evalBindingsOutput = "bindings"
|
|
evalPrettyOutput = "pretty"
|
|
evalSourceOutput = "source"
|
|
|
|
// number of profile results to return by default
|
|
defaultProfileLimit = 10
|
|
|
|
defaultPrettyLimit = 80
|
|
)
|
|
|
|
type regoError struct{}
|
|
|
|
func (regoError) Error() string {
|
|
return "rego"
|
|
}
|
|
|
|
func init() {
|
|
|
|
params := newEvalCommandParams()
|
|
params.profileCriteria = newrepeatedStringFlag([]string{})
|
|
params.profileLimit = newIntFlag(defaultProfileLimit)
|
|
params.prettyLimit = newIntFlag(defaultPrettyLimit)
|
|
|
|
evalCommand := &cobra.Command{
|
|
Use: "eval <query>",
|
|
Short: "Evaluate a Rego query",
|
|
Long: `Evaluate a Rego query and print the result.
|
|
|
|
Examples
|
|
--------
|
|
|
|
To evaluate a simple query:
|
|
|
|
$ opa eval 'x = 1; y = 2; x < y'
|
|
|
|
To evaluate a query against JSON data:
|
|
|
|
$ opa eval --data data.json 'data.names[_] = name'
|
|
|
|
To evaluate a query against JSON data supplied with a file:// URL:
|
|
|
|
$ opa eval --data file:///path/to/file.json 'data'
|
|
|
|
|
|
File & Bundle Loading
|
|
---------------------
|
|
|
|
The --bundle flag will load data files and Rego files contained
|
|
the bundle specified by the path. It can be either a compressed
|
|
tar archive bundle file or a directory tree.
|
|
|
|
$ opa eval --bundle /some/path 'data'
|
|
|
|
Where /some/path contains:
|
|
|
|
foo/
|
|
|
|
|
+-- bar/
|
|
| |
|
|
| +-- data.json
|
|
|
|
|
+-- baz_test.rego
|
|
|
|
|
+-- manifest.yaml
|
|
|
|
The JSON file 'foo/bar/data.json' would be loaded and rooted under
|
|
'data.foo.bar' and the 'foo/baz.rego' would be loaded and rooted under the
|
|
package path contained inside the file. Only data files named data.json or
|
|
data.yaml will be loaded. In the example above the manifest.yaml would be
|
|
ignored.
|
|
|
|
See https://www.openpolicyagent.org/docs/latest/bundles/ for more details
|
|
on bundle directory structures.
|
|
|
|
The --data flag can be used to recursively load ALL *.rego, *.json, and
|
|
*.yaml files under the specified directory.
|
|
|
|
Output Formats
|
|
--------------
|
|
|
|
Set the output format with the --format flag.
|
|
|
|
--format=json : output raw query results as JSON
|
|
--format=values : output line separated JSON arrays containing expression values
|
|
--format=bindings : output line separated JSON objects containing variable bindings
|
|
--format=pretty : output query results in a human-readable format
|
|
`,
|
|
|
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
|
return validateEvalParams(¶ms, args)
|
|
},
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
|
|
defined, err := eval(args, params, os.Stdout)
|
|
if err != nil {
|
|
if _, ok := err.(regoError); !ok {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
os.Exit(2)
|
|
}
|
|
|
|
if (params.fail && !defined) || (params.failDefined && defined) {
|
|
os.Exit(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
// Eval specific flags
|
|
evalCommand.Flags().BoolVarP(¶ms.coverage, "coverage", "", false, "report coverage")
|
|
evalCommand.Flags().BoolVarP(¶ms.partial, "partial", "p", false, "perform partial evaluation")
|
|
evalCommand.Flags().StringSliceVarP(¶ms.unknowns, "unknowns", "u", []string{"input"}, "set paths to treat as unknown during partial evaluation")
|
|
evalCommand.Flags().StringSliceVarP(¶ms.disableInlining, "disable-inlining", "", []string{}, "set paths of documents to exclude from inlining")
|
|
evalCommand.Flags().BoolVar(¶ms.disableIndexing, "disable-indexing", false, "disable indexing optimizations")
|
|
evalCommand.Flags().BoolVarP(¶ms.instrument, "instrument", "", false, "enable query instrumentation metrics (implies --metrics)")
|
|
evalCommand.Flags().BoolVarP(¶ms.profile, "profile", "", false, "perform expression profiling")
|
|
evalCommand.Flags().VarP(¶ms.profileCriteria, "profile-sort", "", "set sort order of expression profiler results")
|
|
evalCommand.Flags().VarP(¶ms.profileLimit, "profile-limit", "", "set number of profiling results to show")
|
|
evalCommand.Flags().VarP(¶ms.prettyLimit, "pretty-limit", "", "set limit after which pretty output gets truncated")
|
|
evalCommand.Flags().BoolVarP(¶ms.failDefined, "fail-defined", "", false, "exits with non-zero exit code on defined/non-empty result and errors")
|
|
|
|
// Shared flags
|
|
addFailFlag(evalCommand.Flags(), ¶ms.fail, false)
|
|
addDataFlag(evalCommand.Flags(), ¶ms.dataPaths)
|
|
addBundleFlag(evalCommand.Flags(), ¶ms.bundlePaths)
|
|
addInputFlag(evalCommand.Flags(), ¶ms.inputPath)
|
|
addImportFlag(evalCommand.Flags(), ¶ms.imports)
|
|
addPackageFlag(evalCommand.Flags(), ¶ms.pkg)
|
|
addQueryStdinFlag(evalCommand.Flags(), ¶ms.stdin)
|
|
addInputStdinFlag(evalCommand.Flags(), ¶ms.stdinInput)
|
|
addMetricsFlag(evalCommand.Flags(), ¶ms.metrics, false)
|
|
addOutputFormat(evalCommand.Flags(), params.outputFormat)
|
|
addIgnoreFlag(evalCommand.Flags(), ¶ms.ignore)
|
|
setExplainFlag(evalCommand.Flags(), params.explain)
|
|
|
|
RootCommand.AddCommand(evalCommand)
|
|
}
|
|
|
|
func eval(args []string, params evalCommandParams, w io.Writer) (bool, error) {
|
|
|
|
ectx, err := setupEval(args, params)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var result pr.Output
|
|
var resultErr error
|
|
|
|
var parsedModules map[string]*ast.Module
|
|
|
|
if !ectx.params.partial {
|
|
var pq rego.PreparedEvalQuery
|
|
pq, resultErr = ectx.r.PrepareForEval(ctx)
|
|
if resultErr == nil {
|
|
parsedModules = pq.Modules()
|
|
result.Result, resultErr = pq.Eval(ctx, ectx.evalArgs...)
|
|
}
|
|
} else {
|
|
var pq rego.PreparedPartialQuery
|
|
pq, resultErr = ectx.r.PrepareForPartial(ctx)
|
|
if resultErr == nil {
|
|
parsedModules = pq.Modules()
|
|
result.Partial, resultErr = pq.Partial(ctx, ectx.evalArgs...)
|
|
}
|
|
}
|
|
|
|
result.Errors = pr.NewOutputErrors(resultErr)
|
|
|
|
if ectx.params.explain != nil {
|
|
switch ectx.params.explain.String() {
|
|
case explainModeFull:
|
|
result.Explanation = *(ectx.tracer)
|
|
case explainModeNotes:
|
|
result.Explanation = lineage.Notes(*(ectx.tracer))
|
|
case explainModeFails:
|
|
result.Explanation = lineage.Fails(*(ectx.tracer))
|
|
}
|
|
}
|
|
|
|
if ectx.metrics != nil {
|
|
result.Metrics = ectx.metrics
|
|
}
|
|
|
|
if ectx.params.profile {
|
|
var sortOrder = pr.DefaultProfileSortOrder
|
|
|
|
if len(ectx.params.profileCriteria.v) != 0 {
|
|
sortOrder = getProfileSortOrder(strings.Split(ectx.params.profileCriteria.String(), ","))
|
|
}
|
|
|
|
result.Profile = ectx.profiler.ReportTopNResults(ectx.params.profileLimit.v, sortOrder)
|
|
}
|
|
|
|
if ectx.params.coverage {
|
|
report := ectx.cover.Report(parsedModules)
|
|
result.Coverage = &report
|
|
}
|
|
|
|
switch params.outputFormat.String() {
|
|
case evalBindingsOutput:
|
|
err = pr.Bindings(w, result)
|
|
case evalValuesOutput:
|
|
err = pr.Values(w, result)
|
|
case evalPrettyOutput:
|
|
err = pr.Pretty(w, result)
|
|
case evalSourceOutput:
|
|
err = pr.Source(w, result)
|
|
default:
|
|
err = pr.JSON(w, result)
|
|
}
|
|
|
|
if err != nil {
|
|
return false, err
|
|
} else if len(result.Errors) > 0 {
|
|
// If the rego package returned an error, return a special error here so
|
|
// that the command doesn't print the same error twice. The error will
|
|
// have been printed above by the presentation package.
|
|
return false, regoError{}
|
|
} else if len(result.Result) == 0 {
|
|
return false, nil
|
|
} else {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
type evalContext struct {
|
|
params evalCommandParams
|
|
metrics metrics.Metrics
|
|
profiler *profiler.Profiler
|
|
cover *cover.Cover
|
|
tracer *topdown.BufferTracer
|
|
r *rego.Rego
|
|
evalArgs []rego.EvalOption
|
|
}
|
|
|
|
func setupEval(args []string, params evalCommandParams) (*evalContext, error) {
|
|
var query string
|
|
|
|
if params.stdin {
|
|
bs, err := ioutil.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
query = string(bs)
|
|
} else {
|
|
query = args[0]
|
|
}
|
|
|
|
info, err := runtime.Term(runtime.Params{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
regoArgs := []func(*rego.Rego){rego.Query(query), rego.Runtime(info)}
|
|
var evalArgs []rego.EvalOption
|
|
|
|
if len(params.imports.v) > 0 {
|
|
regoArgs = append(regoArgs, rego.Imports(params.imports.v))
|
|
}
|
|
|
|
if params.pkg != "" {
|
|
regoArgs = append(regoArgs, rego.Package(params.pkg))
|
|
}
|
|
|
|
if len(params.dataPaths.v) > 0 {
|
|
f := loaderFilter{
|
|
Ignore: checkParams.ignore,
|
|
}
|
|
regoArgs = append(regoArgs, rego.Load(params.dataPaths.v, f.Apply))
|
|
}
|
|
|
|
if params.bundlePaths.isFlagSet() {
|
|
for _, bundleDir := range params.bundlePaths.v {
|
|
regoArgs = append(regoArgs, rego.LoadBundle(bundleDir))
|
|
}
|
|
}
|
|
|
|
inputBytes, err := readInputBytes(params)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if inputBytes != nil {
|
|
var input interface{}
|
|
err := util.Unmarshal(inputBytes, &input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to parse input: %s", err.Error())
|
|
}
|
|
inputValue, err := ast.InterfaceToValue(input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to process input: %s", err.Error())
|
|
}
|
|
regoArgs = append(regoArgs, rego.ParsedInput(inputValue))
|
|
}
|
|
|
|
var tracer *topdown.BufferTracer
|
|
|
|
if params.explain != nil && params.explain.String() != explainModeOff {
|
|
tracer = topdown.NewBufferTracer()
|
|
evalArgs = append(evalArgs, rego.EvalTracer(tracer))
|
|
}
|
|
|
|
if params.disableIndexing {
|
|
evalArgs = append(evalArgs, rego.EvalRuleIndexing(false))
|
|
}
|
|
|
|
var m metrics.Metrics
|
|
if params.metrics {
|
|
m = metrics.New()
|
|
|
|
// Use the same metrics for preparing and evaluating
|
|
regoArgs = append(regoArgs, rego.Metrics(m))
|
|
evalArgs = append(evalArgs, rego.EvalMetrics(m))
|
|
}
|
|
|
|
if params.instrument {
|
|
regoArgs = append(regoArgs, rego.Instrument(true))
|
|
evalArgs = append(evalArgs, rego.EvalInstrument(true))
|
|
}
|
|
|
|
var p *profiler.Profiler
|
|
if params.profile {
|
|
p = profiler.New()
|
|
evalArgs = append(evalArgs, rego.EvalTracer(p))
|
|
}
|
|
|
|
if params.partial {
|
|
regoArgs = append(regoArgs, rego.Unknowns(params.unknowns))
|
|
}
|
|
|
|
regoArgs = append(regoArgs, rego.DisableInlining(params.disableInlining))
|
|
|
|
var c *cover.Cover
|
|
|
|
if params.coverage {
|
|
c = cover.New()
|
|
evalArgs = append(evalArgs, rego.EvalTracer(c))
|
|
}
|
|
|
|
eval := rego.New(regoArgs...)
|
|
|
|
evalCtx := &evalContext{
|
|
params: params,
|
|
metrics: m,
|
|
profiler: p,
|
|
cover: c,
|
|
tracer: tracer,
|
|
r: eval,
|
|
evalArgs: evalArgs,
|
|
}
|
|
|
|
return evalCtx, nil
|
|
}
|
|
|
|
func getProfileSortOrder(sortOrder []string) []string {
|
|
|
|
// convert the sort order slice to a map for faster lookups
|
|
sortOrderMap := make(map[string]bool)
|
|
for _, cr := range sortOrder {
|
|
sortOrderMap[cr] = true
|
|
}
|
|
|
|
// compare the given sort order and the default
|
|
for _, cr := range pr.DefaultProfileSortOrder {
|
|
if _, ok := sortOrderMap[cr]; !ok {
|
|
sortOrder = append(sortOrder, cr)
|
|
}
|
|
}
|
|
return sortOrder
|
|
}
|
|
|
|
func readInputBytes(params evalCommandParams) ([]byte, error) {
|
|
if params.stdinInput {
|
|
return ioutil.ReadAll(os.Stdin)
|
|
} else if params.inputPath != "" {
|
|
path, err := fileurl.Clean(params.inputPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ioutil.ReadFile(path)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
type repeatedStringFlag struct {
|
|
v []string
|
|
isSet bool
|
|
}
|
|
|
|
func newrepeatedStringFlag(val []string) repeatedStringFlag {
|
|
return repeatedStringFlag{
|
|
v: val,
|
|
isSet: false,
|
|
}
|
|
}
|
|
|
|
func (f *repeatedStringFlag) Type() string {
|
|
return "string"
|
|
}
|
|
|
|
func (f *repeatedStringFlag) String() string {
|
|
return strings.Join(f.v, ",")
|
|
}
|
|
|
|
func (f *repeatedStringFlag) Set(s string) error {
|
|
f.v = append(f.v, s)
|
|
f.isSet = true
|
|
return nil
|
|
}
|
|
|
|
func (f *repeatedStringFlag) isFlagSet() bool {
|
|
return f.isSet
|
|
}
|
|
|
|
type intFlag struct {
|
|
v int
|
|
isSet bool
|
|
}
|
|
|
|
func newIntFlag(val int) intFlag {
|
|
return intFlag{
|
|
v: val,
|
|
isSet: false,
|
|
}
|
|
}
|
|
|
|
func (f *intFlag) Type() string {
|
|
return "int"
|
|
}
|
|
|
|
func (f *intFlag) String() string {
|
|
return strconv.Itoa(f.v)
|
|
}
|
|
|
|
func (f *intFlag) Set(s string) error {
|
|
v, err := strconv.ParseInt(s, 0, 64)
|
|
f.v = int(v)
|
|
f.isSet = true
|
|
return err
|
|
}
|
|
|
|
func (f *intFlag) isFlagSet() bool {
|
|
return f.isSet
|
|
}
|