Files
releases/compile/compile.go
T
Torin Sandall fda63bd26a compile: Add support for unknowns other than input
Previously the build command and the compile package would only mark
input as unknown. If documents under data needed to be treated as
unknown, there was no solution. This commit updates the compile
package to infer unknowns based on the bundle roots. If the policy
refers to a data document _outside_ of one of the bundle roots, that
data document will be marked as unknown during optimization/partial
eval.

Fixes #2581

Signed-off-by: Torin Sandall <torinsandall@gmail.com>
2020-08-17 10:34:38 -04:00

794 lines
21 KiB
Go

// Copyright 2020 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 compile implements bundles compilation and linking.
package compile
import (
"context"
"fmt"
"io"
"io/ioutil"
"regexp"
"sort"
"github.com/pkg/errors"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/bundle"
"github.com/open-policy-agent/opa/internal/ref"
initload "github.com/open-policy-agent/opa/internal/runtime/init"
"github.com/open-policy-agent/opa/loader"
"github.com/open-policy-agent/opa/rego"
"github.com/open-policy-agent/opa/storage"
"github.com/open-policy-agent/opa/storage/inmem"
)
const (
// TargetRego is the default target. The source rego is copied (potentially
// rewritten for optimization purpsoes) into the bundle. The target supports
// base documents.
TargetRego = "rego"
// TargetWasm is an alternative target that compiles the policy into a wasm
// module instead of Rego. The target supports base documents.
TargetWasm = "wasm"
)
const wasmResultVar = ast.Var("result")
var validTargets = map[string]struct{}{
TargetRego: struct{}{},
TargetWasm: struct{}{},
}
// Compiler implements bundle compilation and linking.
type Compiler struct {
capabilities *ast.Capabilities // the capabilities that compiled policies may require
bundle *bundle.Bundle // the bundle that the compiler operates on
revision *string // the revision to set on the output bundle
asBundle bool // whether to assume bundle layout on file loading or not
filter loader.Filter // filter to apply to file loader
paths []string // file paths to load. TODO(tsandall): add support for supplying readers for embedded users.
entrypoints orderedStringSet // policy entrypoints required for optimization and certain targets
optimizationLevel int // how aggressive should optimization be
target string // target type (wasm, rego, etc.)
output io.Writer // output stream to write bundle to
entrypointrefs []*ast.Term // validated entrypoints computed from default decision or manually supplied entrypoints
compiler *ast.Compiler // rego ast compiler used for semantic checks and rewriting
debug *debugEvents // debug information produced during build
bvc *bundle.VerificationConfig // represents the key configuration used to verify a signed bundle
bsc *bundle.SigningConfig // represents the key configuration used to generate a signed bundle
keyID string // represents the name of the default key used to verify a signed bundle
}
type debugEvents struct {
debug []Debug
}
func (d *debugEvents) Add(info Debug) {
if d != nil {
d.debug = append(d.debug, info)
}
}
// Debug contains debugging information produced by the build about optimizations and other operations.
type Debug struct {
Location *ast.Location
Message string
}
func (d Debug) String() string {
return fmt.Sprintf("%v: %v", d.Location, d.Message)
}
// New returns a new compiler instance that can be invoked.
func New() *Compiler {
return &Compiler{
asBundle: false,
optimizationLevel: 0,
target: TargetRego,
output: ioutil.Discard,
debug: &debugEvents{},
}
}
// Debug returns a list of debug events produced by the compiler.
func (c *Compiler) Debug() []Debug {
return c.debug.debug
}
// WithRevision sets the revision to include in the output bundle manifest.
func (c *Compiler) WithRevision(r string) *Compiler {
c.revision = &r
return c
}
// WithAsBundle sets file loading mode on the compiler.
func (c *Compiler) WithAsBundle(enabled bool) *Compiler {
c.asBundle = enabled
return c
}
// WithEntrypoints sets the policy entrypoints on the compiler. Entrypoints tell the
// compiler what rules to expect and where optimizations can be targetted. The wasm
// target requires at least one entrypoint as does optimization.
func (c *Compiler) WithEntrypoints(e ...string) *Compiler {
c.entrypoints = c.entrypoints.Append(e...)
return c
}
// WithOptimizationLevel sets the optimization level on the compiler. By default
// optimizations are disabled. Higher levels apply more aggressive optimizations
// but can take longer.
func (c *Compiler) WithOptimizationLevel(n int) *Compiler {
c.optimizationLevel = n
return c
}
// WithTarget sets the output target type to use.
func (c *Compiler) WithTarget(t string) *Compiler {
c.target = t
return c
}
// WithOutput sets the output stream to write the bundle to.
func (c *Compiler) WithOutput(w io.Writer) *Compiler {
c.output = w
return c
}
// WithPaths adds input filepaths to read policy and data from.
func (c *Compiler) WithPaths(p ...string) *Compiler {
c.paths = append(c.paths, p...)
return c
}
// WithFilter sets the loader filter to use when reading non-bundle input files.
func (c *Compiler) WithFilter(filter loader.Filter) *Compiler {
c.filter = filter
return c
}
// WithBundleVerificationConfig sets the key configuration to use to verify a signed bundle
func (c *Compiler) WithBundleVerificationConfig(config *bundle.VerificationConfig) *Compiler {
c.bvc = config
return c
}
// WithBundleSigningConfig sets the key configuration to use to generate a signed bundle
func (c *Compiler) WithBundleSigningConfig(config *bundle.SigningConfig) *Compiler {
c.bsc = config
return c
}
// WithBundleVerificationKeyID sets the key to use to verify a signed bundle.
// If provided, the "keyid" claim in the bundle signature, will be set to this value
func (c *Compiler) WithBundleVerificationKeyID(keyID string) *Compiler {
c.keyID = keyID
return c
}
// WithCapabilities sets the capabilities to use while checking policies.
func (c *Compiler) WithCapabilities(capabilities *ast.Capabilities) *Compiler {
c.capabilities = capabilities
return c
}
// Build compiles and links the input files and outputs a bundle to the writer.
func (c *Compiler) Build(ctx context.Context) error {
if err := c.init(); err != nil {
return err
}
if err := c.initBundle(); err != nil {
return err
}
if err := c.optimize(ctx); err != nil {
return err
}
if c.target == TargetWasm {
if err := c.compileWasm(ctx); err != nil {
return err
}
}
if c.revision != nil {
c.bundle.Manifest.Revision = *c.revision
}
if err := c.bundle.FormatModules(false); err != nil {
return err
}
if c.bsc != nil {
if err := c.bundle.GenerateSignature(c.bsc, c.keyID, false); err != nil {
return err
}
}
return bundle.NewWriter(c.output).Write(*c.bundle)
}
func (c *Compiler) init() error {
if c.capabilities == nil {
c.capabilities = ast.CapabilitiesForThisVersion()
}
if _, ok := validTargets[c.target]; !ok {
return fmt.Errorf("invalid target %q", c.target)
}
for _, e := range c.entrypoints {
r, err := ref.ParseDataPath(e)
if err != nil {
return fmt.Errorf("entrypoint %v not valid: use <package>/<rule>", e)
}
if len(r) <= 2 {
return fmt.Errorf("entrypoint %v too short: use <package>/<rule>", e)
}
c.entrypointrefs = append(c.entrypointrefs, ast.NewTerm(r))
}
if c.optimizationLevel > 0 && len(c.entrypointrefs) == 0 {
return errors.New("bundle optimizations require at least one entrypoint")
}
if c.target == TargetWasm && len(c.entrypointrefs) != 1 {
return errors.New("wasm compilation requires exactly one entrypoint")
}
return nil
}
func (c *Compiler) initBundle() error {
// TODO(tsandall): the metrics object should passed through here so we that
// we can track read and parse times.
load, err := initload.LoadPaths(c.paths, c.filter, c.asBundle, c.bvc, false)
if err != nil {
return errors.Wrap(err, "load error")
}
if c.asBundle {
var names []string
for k := range load.Bundles {
names = append(names, k)
}
sort.Strings(names)
var bundles []*bundle.Bundle
for _, k := range names {
bundles = append(bundles, load.Bundles[k])
}
result, err := bundle.Merge(bundles)
if err != nil {
return fmt.Errorf("bundle merge failed: %v", err)
}
c.bundle = result
return nil
}
// TODO(tsandall): add support for controlling roots. Either the caller could
// supply them or the compiler could infer them based on the packages and data
// contents. The latter would require changes to the loader to preserve the
// locations where base documents were mounted under data.
result := &bundle.Bundle{}
result.Manifest.Init()
result.Data = load.Files.Documents
var modules []string
for k := range load.Files.Modules {
modules = append(modules, k)
}
sort.Strings(modules)
for _, module := range modules {
result.Modules = append(result.Modules, bundle.ModuleFile{
URL: load.Files.Modules[module].Name,
Path: load.Files.Modules[module].Name,
Parsed: load.Files.Modules[module].Parsed,
Raw: load.Files.Modules[module].Raw,
})
}
c.bundle = result
return nil
}
func (c *Compiler) optimize(ctx context.Context) error {
if c.optimizationLevel <= 0 {
var err error
c.compiler, err = compile(c.capabilities, c.bundle)
return err
}
o := newOptimizer(c.capabilities, c.bundle).
WithEntrypoints(c.entrypointrefs).
WithDebug(c.debug).
WithShallowInlining(c.optimizationLevel <= 1)
err := o.Do(ctx)
if err != nil {
return err
}
c.bundle = o.Bundle()
return nil
}
func (c *Compiler) compileWasm(ctx context.Context) error {
// Lazily compile the modules if needed. If optimizations were run, the
// AST compiler will not be set because the default target does not require it.
if c.compiler == nil {
var err error
c.compiler, err = compile(c.capabilities, c.bundle)
if err != nil {
return err
}
}
store := inmem.NewFromObject(c.bundle.Data)
resultSym := ast.NewTerm(wasmResultVar)
cr, err := rego.New(
rego.ParsedQuery(ast.NewBody(ast.Equality.Expr(resultSym, c.entrypointrefs[0]))),
rego.Compiler(c.compiler),
rego.Store(store),
).Compile(ctx)
if err != nil {
return err
}
c.bundle.Wasm = cr.Bytes
return nil
}
type undefinedEntrypointErr struct {
Entrypoint *ast.Term
}
func (err undefinedEntrypointErr) Error() string {
return fmt.Sprintf("undefined entrypoint %v", err.Entrypoint)
}
type optimizer struct {
capabilities *ast.Capabilities
bundle *bundle.Bundle
compiler *ast.Compiler
entrypoints []*ast.Term
nsprefix string
resultsymprefix string
outputprefix string
shallow bool
debug *debugEvents
}
func newOptimizer(c *ast.Capabilities, b *bundle.Bundle) *optimizer {
return &optimizer{
capabilities: c,
bundle: b,
nsprefix: "partial",
resultsymprefix: ast.WildcardPrefix,
outputprefix: "optimized",
}
}
func (o *optimizer) WithDebug(debug *debugEvents) *optimizer {
o.debug = debug
return o
}
func (o *optimizer) WithEntrypoints(es []*ast.Term) *optimizer {
o.entrypoints = es
return o
}
func (o *optimizer) WithShallowInlining(yes bool) *optimizer {
o.shallow = yes
return o
}
func (o *optimizer) Do(ctx context.Context) error {
// NOTE(tsandall): if there are multiple entrypoints, copy the bundle because
// if any of the optimization steps fail, we do not want to leave the caller's
// bundle in a partially modified state.
if len(o.entrypoints) > 1 {
cpy := o.bundle.Copy()
o.bundle = &cpy
}
// initialize other inputs to the optimization process (store, symbols, etc.)
data := o.bundle.Data
if data == nil {
data = map[string]interface{}{}
}
store := inmem.NewFromObject(data)
resultsym := ast.VarTerm(o.resultsymprefix + "__result__")
usedFilenames := map[string]int{}
var unknowns []*ast.Term
// NOTE(tsandall): the entrypoints are optimized in order so that the optimization
// of entrypoint[1] sees the optimization of entrypoint[0] and so on. This is needed
// because otherwise the optimization outputs (e.g., support rules) would have to
// merged somehow. Instead of dealing with that, just run the optimizations in the
// order the user supplied the entrypoints in.
for i, e := range o.entrypoints {
var err error
o.compiler, err = compile(o.capabilities, o.bundle)
if err != nil {
return err
}
if unknowns == nil {
unknowns = o.findUnknowns()
}
r := rego.New(
rego.ParsedQuery(ast.NewBody(ast.Equality.Expr(resultsym, e))),
rego.PartialNamespace(o.nsprefix),
rego.DisableInlining(o.findRequiredDocuments(e)),
rego.ShallowInlining(o.shallow),
rego.SkipPartialNamespace(true),
rego.ParsedUnknowns(unknowns),
rego.Compiler(o.compiler),
rego.Store(store),
)
pq, err := r.Partial(ctx)
if err != nil {
return err
}
// NOTE(tsandall): this might be a bit too strict but in practice it's
// unlikely users will want to ignore undefined entrypoints. make this
// optional in the future.
if len(pq.Queries) == 0 {
return undefinedEntrypointErr{Entrypoint: e}
}
if module := o.getSupportForEntrypoint(pq.Queries, e, resultsym); module != nil {
pq.Support = append(pq.Support, module)
}
modules := make([]bundle.ModuleFile, len(pq.Support))
for j := range pq.Support {
fileName := o.getSupportModuleFilename(usedFilenames, pq.Support[j], i, j)
modules[j] = bundle.ModuleFile{
URL: fileName,
Path: fileName,
Parsed: pq.Support[j],
}
}
o.bundle.Modules = o.merge(o.bundle.Modules, modules)
}
sort.Slice(o.bundle.Modules, func(i, j int) bool {
return o.bundle.Modules[i].URL < o.bundle.Modules[j].URL
})
// NOTE(tsandall): prune out rules and data that are not referenced in the bundle
// in the future.
o.bundle.Manifest.AddRoot(o.nsprefix)
o.bundle.Manifest.Revision = ""
return nil
}
func (o *optimizer) Bundle() *bundle.Bundle {
return o.bundle
}
func (o *optimizer) findRequiredDocuments(ref *ast.Term) []string {
keep := map[string]*ast.Location{}
deps := map[*ast.Rule]struct{}{}
for _, r := range o.compiler.GetRules(ref.Value.(ast.Ref)) {
transitiveDependents(o.compiler, r, deps)
}
for rule := range deps {
ast.WalkExprs(rule, func(expr *ast.Expr) bool {
for _, with := range expr.With {
// TODO(tsandall): this should be improved to exclude refs that are
// marked as unknown. Since the build command does not allow users to
// set unknowns, we can hardcode to assume 'input'.
if !with.Target.Value.(ast.Ref).HasPrefix(ast.InputRootRef) {
keep[with.Target.String()] = with.Target.Location
}
}
return false
})
}
var result []string
for k := range keep {
result = append(result, k)
}
sort.Strings(result)
for _, k := range result {
o.debug.Add(Debug{
Location: keep[k],
Message: fmt.Sprintf("disables inlining of %v", k),
})
}
return result
}
func (o *optimizer) findUnknowns() []*ast.Term {
// Initialize set of refs representing the bundle roots.
refs := newRefSet(stringsToRefs(*o.bundle.Manifest.Roots)...)
// Initialize set of refs for the result (i.e., refs outside the bundle roots.)
unknowns := newRefSet(ast.InputRootRef)
// Find data references that are not prefixed by one of the roots.
for _, module := range o.compiler.Modules {
ast.WalkRefs(module, func(x ast.Ref) bool {
prefix := x.ConstantPrefix()
if !prefix.HasPrefix(ast.DefaultRootRef) {
return true
}
if !refs.ContainsPrefix(prefix) {
o.debug.Add(Debug{
Location: x[0].Location,
Message: fmt.Sprintf("marking %v as unknown", prefix),
})
unknowns.AddPrefix(prefix)
}
return false
})
}
return unknowns.Sorted()
}
func (o *optimizer) getSupportForEntrypoint(queries []ast.Body, e *ast.Term, resultsym *ast.Term) *ast.Module {
path := e.Value.(ast.Ref)
name := ast.Var(path[len(path)-1].Value.(ast.String))
module := &ast.Module{Package: &ast.Package{Path: path[:len(path)-1]}}
for _, query := range queries {
// NOTE(tsandall): when the query refers to the original entrypoint, throw it
// away since this would create a recursive rule--this occurs if the entrypoint
// cannot be partially evaluated.
stop := false
ast.WalkRefs(query, func(x ast.Ref) bool {
if !stop {
if x.HasPrefix(path) {
stop = true
}
}
return stop
})
if stop {
return nil
}
module.Rules = append(module.Rules, &ast.Rule{
Head: ast.NewHead(name, nil, resultsym),
Body: query,
Module: module,
})
}
return module
}
// merge combines two sets of modules and returns the result. The rules from modules
// in 'b' override rules from modules in 'a'. If all rules in a module in 'a' are overridden
// by rules in modules in 'b' then the module from 'a' is discarded.
func (o *optimizer) merge(a, b []bundle.ModuleFile) []bundle.ModuleFile {
prefixes := ast.NewSet()
for i := range b {
// NOTE(tsandall): use a set to memoize the prefix add operation--it's only
// needed once per rule set and constructing the path for every rule in the
// module could expensive for PE output (which can contain hundreds of thousands
// of rules.)
seen := ast.NewVarSet()
for _, rule := range b[i].Parsed.Rules {
if _, ok := seen[rule.Head.Name]; !ok {
prefixes.Add(ast.NewTerm(rule.Path()))
seen.Add(rule.Head.Name)
}
}
}
for i := range a {
var keep []*ast.Rule
// NOTE(tsandall): same as above--memoize keep/discard decision. If multiple
// entrypoints are provided the dst module may contain a large number of rules.
seen := ast.NewVarSet()
discard := ast.NewVarSet()
for _, rule := range a[i].Parsed.Rules {
if _, ok := discard[rule.Head.Name]; ok {
continue
} else if _, ok := seen[rule.Head.Name]; ok {
keep = append(keep, rule)
continue
}
path := rule.Path()
overlap := prefixes.Until(func(x *ast.Term) bool {
ref := x.Value.(ast.Ref)
return path.HasPrefix(ref)
})
if overlap {
discard.Add(rule.Head.Name)
} else {
seen.Add(rule.Head.Name)
keep = append(keep, rule)
}
}
if len(keep) > 0 {
a[i].Parsed.Rules = keep
a[i].Raw = nil
b = append(b, a[i])
}
}
return b
}
func (o *optimizer) getSupportModuleFilename(used map[string]int, module *ast.Module, entrypointIndex int, supportIndex int) string {
fileName, err := module.Package.Path.Ptr()
if err == nil && safePathPattern.MatchString(fileName) {
fileName = o.outputprefix + "/" + fileName
if c, ok := used[fileName]; ok {
fileName += fmt.Sprintf(".%d", c)
}
used[fileName]++
fileName += ".rego"
return fileName
}
return fmt.Sprintf("%v/%v/%v/%v.rego", o.outputprefix, o.nsprefix, entrypointIndex, supportIndex)
}
var safePathPattern = regexp.MustCompile(`^[\w-_/]+$`)
func compile(c *ast.Capabilities, b *bundle.Bundle) (*ast.Compiler, error) {
modules := map[string]*ast.Module{}
for _, mf := range b.Modules {
modules[mf.URL] = mf.Parsed
}
compiler := ast.NewCompiler().WithCapabilities(c)
compiler.Compile(modules)
if compiler.Failed() {
return nil, compiler.Errors
}
return compiler, nil
}
func transitiveDependents(compiler *ast.Compiler, rule *ast.Rule, deps map[*ast.Rule]struct{}) {
for x := range compiler.Graph.Dependents(rule) {
other := x.(*ast.Rule)
deps[other] = struct{}{}
transitiveDependents(compiler, other, deps)
}
}
type orderedStringSet []string
func (ss orderedStringSet) Append(s ...string) orderedStringSet {
for _, x := range s {
var found bool
for _, other := range ss {
if x == other {
found = true
}
}
if !found {
ss = append(ss, x)
}
}
return ss
}
func stringsToRefs(x []string) []ast.Ref {
result := make([]ast.Ref, len(x))
for i := range result {
result[i] = storage.MustParsePath("/" + x[i]).Ref(ast.DefaultRootDocument)
}
return result
}
type refSet struct {
s []ast.Ref
}
func newRefSet(x ...ast.Ref) *refSet {
result := &refSet{}
for i := range x {
result.AddPrefix(x[i])
}
return result
}
// ContainsPrefix returns true if r is prefixed by any of the existing refs in the set.
func (rs *refSet) ContainsPrefix(r ast.Ref) bool {
for i := range rs.s {
if r.HasPrefix(rs.s[i]) {
return true
}
}
return false
}
// AddPrefix inserts r into the set if r is not prefixed by any existing
// refs in the set. If any existing refs are prefixed by r, those existing
// refs are removed.
func (rs *refSet) AddPrefix(r ast.Ref) {
if rs.ContainsPrefix(r) {
return
}
cpy := []ast.Ref{r}
for i := range rs.s {
if !rs.s[i].HasPrefix(r) {
cpy = append(cpy, rs.s[i])
}
}
rs.s = cpy
}
// Sorted returns a sorted slice of terms for refs in the set.
func (rs *refSet) Sorted() []*ast.Term {
terms := make([]*ast.Term, len(rs.s))
for i := range rs.s {
terms[i] = ast.NewTerm(rs.s[i])
}
sort.Slice(terms, func(i, j int) bool {
return terms[i].Value.Compare(terms[j].Value) < 0
})
return terms
}