commands: implement policy test command
Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
@@ -20,7 +20,7 @@ func RootCmd(rootcmd *cobra.Command, dockerCli command.Cli, rootOpts RootOptions
|
|||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
jsonSchemaCmd(),
|
jsonSchemaCmd(),
|
||||||
evalCmd(dockerCli, rootOpts),
|
evalCmd(dockerCli, rootOpts),
|
||||||
testCmd(),
|
testCmd(dockerCli, rootOpts),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
+193
-5
@@ -1,24 +1,212 @@
|
|||||||
package policy
|
package policy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/docker/buildx/builder"
|
||||||
|
"github.com/docker/buildx/policy"
|
||||||
|
"github.com/docker/buildx/util/cobrautil"
|
||||||
|
"github.com/docker/buildx/util/confutil"
|
||||||
|
"github.com/docker/cli/cli/command"
|
||||||
|
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
|
||||||
|
"github.com/moby/buildkit/solver/pb"
|
||||||
|
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCmd() *cobra.Command {
|
func testCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
|
||||||
|
var opts policy.TestOptions
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "test <path>",
|
Use: "test <path>",
|
||||||
Short: "Run policy tests",
|
Short: "Run policy tests",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
DisableFlagsInUseLine: true,
|
DisableFlagsInUseLine: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runTest(args[0])
|
resolver := newPolicyTestResolver(dockerCli, rootOpts.Builder)
|
||||||
|
opts.Resolver = resolver.Options()
|
||||||
|
defer resolver.Close()
|
||||||
|
return runTest(cmd.Context(), cmd.OutOrStdout(), args[0], opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVar(&opts.Run, "run", "", "Run only tests with name containing this substring")
|
||||||
|
cmd.Flags().StringVar(&opts.Filename, "filename", "Dockerfile", "Name of the Dockerfile to validate")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTest(path string) error {
|
func runTest(ctx context.Context, out io.Writer, path string, opts policy.TestOptions) error {
|
||||||
_ = path
|
root := os.DirFS(".")
|
||||||
return errors.New("not implemented")
|
statFS, ok := root.(fs.StatFS)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("policy test root does not support stat")
|
||||||
|
}
|
||||||
|
opts.Root = statFS
|
||||||
|
|
||||||
|
summary, err := policy.RunPolicyTests(ctx, path, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, result := range summary.Results {
|
||||||
|
status := "PASS"
|
||||||
|
if !result.Passed {
|
||||||
|
status = "FAIL"
|
||||||
|
}
|
||||||
|
allowStr := "n/a"
|
||||||
|
if result.Allow != nil {
|
||||||
|
allowStr = fmt.Sprintf("%v", *result.Allow)
|
||||||
|
}
|
||||||
|
if len(result.DenyMessages) > 0 {
|
||||||
|
_, _ = fmt.Fprintf(out, "%s: %s (allow=%s, deny_msg=%s)\n", result.Name, status, allowStr, strings.Join(result.DenyMessages, "; "))
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(out, "%s: %s (allow=%s)\n", result.Name, status, allowStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Passed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Input != nil {
|
||||||
|
writeJSON(out, "input", result.Input)
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintln(out, "input: <nil>")
|
||||||
|
}
|
||||||
|
if result.Decision != nil {
|
||||||
|
writeJSON(out, "decision", result.Decision)
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintln(out, "decision: <nil>")
|
||||||
|
}
|
||||||
|
if len(result.MissingInput) > 0 {
|
||||||
|
_, _ = fmt.Fprintf(out, "missing_input: %s\n", strings.Join(result.MissingInput, ", "))
|
||||||
|
}
|
||||||
|
if len(result.MetadataNeeded) > 0 {
|
||||||
|
_, _ = fmt.Fprintf(out, "metadata_resolve: %s\n", strings.Join(result.MetadataNeeded, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
return cobrautil.ExitCodeError(1)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(out io.Writer, label string, v any) {
|
||||||
|
dt, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(out, "%s: <error encoding>\n", label)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(out, "%s:\n%s\n", label, string(dt))
|
||||||
|
}
|
||||||
|
|
||||||
|
type policyTestResolver struct {
|
||||||
|
dockerCli command.Cli
|
||||||
|
builderName *string
|
||||||
|
|
||||||
|
once sync.Once
|
||||||
|
platform *ocispecs.Platform
|
||||||
|
openClient gatewayClientOpener
|
||||||
|
release func() error
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPolicyTestResolver(dockerCli command.Cli, builderName *string) *policyTestResolver {
|
||||||
|
return &policyTestResolver{
|
||||||
|
dockerCli: dockerCli,
|
||||||
|
builderName: builderName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *policyTestResolver) Options() *policy.TestResolver {
|
||||||
|
return &policy.TestResolver{
|
||||||
|
Resolve: r.Resolve,
|
||||||
|
Platform: r.Platform,
|
||||||
|
VerifierProvider: policy.SignatureVerifier(confutil.NewConfig(r.dockerCli)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *policyTestResolver) Close() error {
|
||||||
|
if r.release == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *policyTestResolver) Platform(ctx context.Context) (*ocispecs.Platform, error) {
|
||||||
|
if err := r.init(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.platform, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *policyTestResolver) Resolve(ctx context.Context, source *pb.SourceOp, req *gwpb.ResolveSourceMetaRequest) (*gwpb.ResolveSourceMetaResponse, error) {
|
||||||
|
if err := r.init(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gwClient, err := r.openClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opt := sourceResolverOpt(req, r.platform)
|
||||||
|
resp, err := gwClient.ResolveSourceMetadata(ctx, source, opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildSourceMetaResponse(resp, req), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *policyTestResolver) init(ctx context.Context) error {
|
||||||
|
r.once.Do(func() {
|
||||||
|
bopts := []builder.Option{}
|
||||||
|
if r.builderName != nil {
|
||||||
|
bopts = append(bopts, builder.WithName(*r.builderName))
|
||||||
|
}
|
||||||
|
b, err := builder.New(r.dockerCli, bopts...)
|
||||||
|
if err != nil {
|
||||||
|
r.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := b.LoadNodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
r.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := nodes[0].Driver.Client(ctx)
|
||||||
|
if err != nil {
|
||||||
|
r.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
workers, err := c.ListWorkers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
r.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(workers) == 0 {
|
||||||
|
r.err = errors.New("no workers available in the builder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPlatform := workers[0].Platforms[0]
|
||||||
|
r.platform = &ocispecs.Platform{
|
||||||
|
Architecture: defaultPlatform.Architecture,
|
||||||
|
OS: defaultPlatform.OS,
|
||||||
|
Variant: defaultPlatform.Variant,
|
||||||
|
}
|
||||||
|
openClient, release, err := gatewayClientFactory(c)
|
||||||
|
if err != nil {
|
||||||
|
r.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.openClient = openClient
|
||||||
|
r.release = release
|
||||||
|
})
|
||||||
|
return r.err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ Run policy tests
|
|||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
|:----------------|:---------|:--------|:-----------------------------------------|
|
|:----------------|:---------|:-------------|:---------------------------------------------------|
|
||||||
| `--builder` | `string` | | Override the configured builder instance |
|
| `--builder` | `string` | | Override the configured builder instance |
|
||||||
| `-D`, `--debug` | `bool` | | Enable debug logging |
|
| `-D`, `--debug` | `bool` | | Enable debug logging |
|
||||||
|
| `--filename` | `string` | `Dockerfile` | Name of the Dockerfile to validate |
|
||||||
|
| `--run` | `string` | | Run only tests with name containing this substring |
|
||||||
|
|
||||||
|
|
||||||
<!---MARKER_GEN_END-->
|
<!---MARKER_GEN_END-->
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,835 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/containerd/platforms"
|
||||||
|
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
|
||||||
|
"github.com/moby/buildkit/solver/pb"
|
||||||
|
"github.com/moby/buildkit/sourcepolicy/policysession"
|
||||||
|
"github.com/open-policy-agent/opa/v1/ast"
|
||||||
|
"github.com/open-policy-agent/opa/v1/rego"
|
||||||
|
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestOptions struct {
|
||||||
|
Run string
|
||||||
|
Filename string
|
||||||
|
Root fs.StatFS
|
||||||
|
Resolver *TestResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestSummary struct {
|
||||||
|
Results []TestResult
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestResult struct {
|
||||||
|
Name string
|
||||||
|
Package string
|
||||||
|
Passed bool
|
||||||
|
Allow *bool
|
||||||
|
DenyMessages []string
|
||||||
|
Input *Input
|
||||||
|
Decision *Decision
|
||||||
|
MissingInput []string
|
||||||
|
MetadataNeeded []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type testDef struct {
|
||||||
|
Name string
|
||||||
|
PkgPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestResolver struct {
|
||||||
|
Resolve func(context.Context, *pb.SourceOp, *gwpb.ResolveSourceMetaRequest) (*gwpb.ResolveSourceMetaResponse, error)
|
||||||
|
Platform func(context.Context) (*ocispecs.Platform, error)
|
||||||
|
VerifierProvider PolicyVerifierProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunPolicyTests(ctx context.Context, path string, opts TestOptions) (TestSummary, error) {
|
||||||
|
var summary TestSummary
|
||||||
|
if opts.Root == nil {
|
||||||
|
return summary, errors.New("policy root filesystem is required")
|
||||||
|
}
|
||||||
|
policyModules, policyFiles, err := loadPolicyModules(opts.Root, opts.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return summary, err
|
||||||
|
}
|
||||||
|
|
||||||
|
testModules, _, err := LoadTestModules(opts.Root, path)
|
||||||
|
if err != nil {
|
||||||
|
return summary, err
|
||||||
|
}
|
||||||
|
|
||||||
|
modules := make(map[string]*ast.Module, len(policyModules)+len(testModules))
|
||||||
|
maps.Copy(modules, policyModules)
|
||||||
|
maps.Copy(modules, testModules)
|
||||||
|
|
||||||
|
fsProvider := func() (fs.StatFS, func() error, error) {
|
||||||
|
return opts.Root, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p := NewPolicy(Opt{
|
||||||
|
FS: fsProvider,
|
||||||
|
})
|
||||||
|
|
||||||
|
comp, closeLoader, err := compilePolicyModules(modules, p, fsProvider)
|
||||||
|
if err != nil {
|
||||||
|
if closeLoader != nil {
|
||||||
|
_ = closeLoader()
|
||||||
|
}
|
||||||
|
return summary, err
|
||||||
|
}
|
||||||
|
if closeLoader != nil {
|
||||||
|
defer closeLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := findPolicyTests(testModules)
|
||||||
|
if opts.Run != "" {
|
||||||
|
tests = filterPolicyTests(tests, opts.Run)
|
||||||
|
}
|
||||||
|
if len(tests) == 0 {
|
||||||
|
return summary, errors.New("no tests found")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tests {
|
||||||
|
result, err := runPolicyTest(ctx, policyModules, testModules, policyFiles, comp, p, t, opts, fsProvider)
|
||||||
|
if err != nil {
|
||||||
|
return summary, err
|
||||||
|
}
|
||||||
|
if !result.Passed {
|
||||||
|
summary.Failed++
|
||||||
|
}
|
||||||
|
summary.Results = append(summary.Results, result)
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadTestModules(root fs.StatFS, path string) (map[string]*ast.Module, []File, error) {
|
||||||
|
path = filepath.ToSlash(path)
|
||||||
|
path = strings.TrimSuffix(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "."
|
||||||
|
}
|
||||||
|
info, err := root.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrapf(err, "stat %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
if info.IsDir() {
|
||||||
|
entries, err := fs.ReadDir(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrapf(err, "read dir %s", path)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(name, "_test.rego") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, filepath.ToSlash(filepath.Join(path, name)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !strings.HasSuffix(path, "_test.rego") {
|
||||||
|
return nil, nil, errors.Errorf("test file must have _test.rego suffix: %s", path)
|
||||||
|
}
|
||||||
|
files = append(files, filepath.ToSlash(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, nil, errors.New("no policy tests found")
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(files)
|
||||||
|
modules := make(map[string]*ast.Module, len(files))
|
||||||
|
entries := make([]File, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
dt, err := fs.ReadFile(root, file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrapf(err, "read policy test module %s", file)
|
||||||
|
}
|
||||||
|
mod, err := ast.ParseModuleWithOpts(file, string(dt), ast.ParserOptions{
|
||||||
|
RegoVersion: ast.RegoV1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrapf(err, "parse policy test module %s", file)
|
||||||
|
}
|
||||||
|
modules[file] = mod
|
||||||
|
entries = append(entries, File{
|
||||||
|
Filename: file,
|
||||||
|
Data: dt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return modules, entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPolicyModules(root fs.StatFS, filename string) (map[string]*ast.Module, []File, error) {
|
||||||
|
if filename == "" {
|
||||||
|
return nil, nil, errors.New("policy filename is required")
|
||||||
|
}
|
||||||
|
policyFile := filename + ".rego"
|
||||||
|
dt, err := fs.ReadFile(root, policyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrapf(err, "read policy module %s", policyFile)
|
||||||
|
}
|
||||||
|
mod, err := ast.ParseModuleWithOpts(policyFile, string(dt), ast.ParserOptions{
|
||||||
|
RegoVersion: ast.RegoV1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrapf(err, "parse policy module %s", policyFile)
|
||||||
|
}
|
||||||
|
modules := map[string]*ast.Module{
|
||||||
|
filepath.ToSlash(policyFile): mod,
|
||||||
|
}
|
||||||
|
files := []File{
|
||||||
|
{
|
||||||
|
Filename: filepath.ToSlash(policyFile),
|
||||||
|
Data: dt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return modules, files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compilePolicyModules(modules map[string]*ast.Module, p *Policy, fsProvider func() (fs.StatFS, func() error, error)) (*ast.Compiler, func() error, error) {
|
||||||
|
caps := &ast.Capabilities{
|
||||||
|
Builtins: builtins(),
|
||||||
|
Features: slices.Clone(ast.Features),
|
||||||
|
}
|
||||||
|
comp := ast.NewCompiler().WithCapabilities(caps).WithKeepModules(true)
|
||||||
|
|
||||||
|
builtinDefs := make(map[string]*ast.Builtin)
|
||||||
|
for _, f := range p.funcs {
|
||||||
|
builtinDefs[f.decl.Name] = &ast.Builtin{
|
||||||
|
Name: f.decl.Name,
|
||||||
|
Decl: f.decl.Decl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comp = comp.WithBuiltins(builtinDefs)
|
||||||
|
|
||||||
|
loader, closeLoader := newPolicyModuleLoader(fsProvider)
|
||||||
|
comp = comp.WithModuleLoader(loader)
|
||||||
|
|
||||||
|
comp.Compile(modules)
|
||||||
|
if comp.Failed() {
|
||||||
|
return nil, closeLoader, errors.Errorf("compile: %v", comp.Errors)
|
||||||
|
}
|
||||||
|
return comp, closeLoader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPolicyTests(modules map[string]*ast.Module) []testDef {
|
||||||
|
seen := map[string]testDef{}
|
||||||
|
for _, mod := range modules {
|
||||||
|
pkgPath := mod.Package.Path.String()
|
||||||
|
for _, rule := range mod.Rules {
|
||||||
|
if len(rule.Head.Args) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.Head.Value != nil {
|
||||||
|
if _, ok := rule.Head.Value.Value.(ast.Boolean); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := string(rule.Head.Name)
|
||||||
|
if strings.HasPrefix(name, "test_") {
|
||||||
|
seen[name] = testDef{Name: name, PkgPath: pkgPath}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]testDef, 0, len(seen))
|
||||||
|
for _, t := range seen {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterPolicyTests(tests []testDef, substr string) []testDef {
|
||||||
|
out := make([]testDef, 0, len(tests))
|
||||||
|
for _, t := range tests {
|
||||||
|
if strings.Contains(t.Name, substr) {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPolicyTest(ctx context.Context, policyModules map[string]*ast.Module, testModules map[string]*ast.Module, policyFiles []File, compiler *ast.Compiler, p *Policy, t testDef, opts TestOptions, fsProvider func() (fs.StatFS, func() error, error)) (TestResult, error) {
|
||||||
|
result := TestResult{
|
||||||
|
Name: t.Name,
|
||||||
|
Package: t.PkgPath,
|
||||||
|
}
|
||||||
|
policyPackageModules := modulesForPackage(policyModules, t.PkgPath)
|
||||||
|
input, err := lookupTestInput(testModules, t)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
effectiveInput := input
|
||||||
|
if opts.Resolver != nil {
|
||||||
|
resolvedInput, ok, err := resolveTestInput(ctx, policyFiles, opts.Resolver, policyPackageModules, input, fsProvider)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
effectiveInput = resolvedInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Input = effectiveInput
|
||||||
|
|
||||||
|
testState := stateFromInput(effectiveInput)
|
||||||
|
query := fmt.Sprintf("%s.%s", t.PkgPath, t.Name)
|
||||||
|
ok, err := evalBool(ctx, compiler, p, testState, query, effectiveInput)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result.Passed = ok
|
||||||
|
|
||||||
|
decisionState := stateFromInput(effectiveInput)
|
||||||
|
decision, allow, deny := evalDecision(ctx, compiler, p, decisionState, t.PkgPath, effectiveInput)
|
||||||
|
result.Decision = decision
|
||||||
|
result.Allow = allow
|
||||||
|
result.DenyMessages = deny
|
||||||
|
|
||||||
|
missing := missingInputRefs(policyPackageModules, effectiveInput)
|
||||||
|
result.MissingInput = uniqueSortedStrings(missing)
|
||||||
|
result.MetadataNeeded = summarizeMetadataRequests(result.MissingInput)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupTestInput(testModules map[string]*ast.Module, t testDef) (*Input, error) {
|
||||||
|
if len(testModules) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var inputTerm *ast.Term
|
||||||
|
for _, mod := range modulesForPackage(testModules, t.PkgPath) {
|
||||||
|
for _, rule := range mod.Rules {
|
||||||
|
if string(rule.Head.Name) != t.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
term, err := inputTermFromRule(rule, t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if term == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inputTerm != nil && !inputTerm.Equal(term) {
|
||||||
|
return nil, errors.Errorf("multiple input overrides for %s", t.Name)
|
||||||
|
}
|
||||||
|
inputTerm = term
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inputTerm == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var inp Input
|
||||||
|
if err := ast.As(inputTerm.Value, &inp); err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "failed to decode test input for %s", t.Name)
|
||||||
|
}
|
||||||
|
return &inp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputTermFromRule(rule *ast.Rule, t testDef) (*ast.Term, error) {
|
||||||
|
var inputTerm *ast.Term
|
||||||
|
for _, expr := range rule.Body {
|
||||||
|
for _, w := range expr.With {
|
||||||
|
if w == nil || w.Target == nil || w.Value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref, ok := w.Target.Value.(ast.Ref)
|
||||||
|
if !ok || !ref.Equal(ast.InputRootRef) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inputTerm != nil && !inputTerm.Equal(w.Value) {
|
||||||
|
return nil, errors.Errorf("multiple input overrides for %s", t.Name)
|
||||||
|
}
|
||||||
|
inputTerm = w.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inputTerm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalDecision(ctx context.Context, compiler *ast.Compiler, p *Policy, st *state, pkgPath string, input *Input) (*Decision, *bool, []string) {
|
||||||
|
query := fmt.Sprintf("%s.decision", pkgPath)
|
||||||
|
val, err := evalValue(ctx, compiler, p, st, query, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
decision := decodeDecision(val)
|
||||||
|
if decision == nil {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
deny := decision.DenyMessages
|
||||||
|
if len(deny) == 0 {
|
||||||
|
deny = nil
|
||||||
|
}
|
||||||
|
return decision, decision.Allow, deny
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateFromInput(input *Input) *state {
|
||||||
|
st := &state{}
|
||||||
|
if input == nil {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
st.Input = *input
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTestInput(ctx context.Context, files []File, resolver *TestResolver, policyModules []*ast.Module, input *Input, fsProvider func() (fs.StatFS, func() error, error)) (*Input, bool, error) {
|
||||||
|
if resolver == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
source, err := sourceFromInput(input)
|
||||||
|
if err != nil || source == nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
platform, err := inputPlatform(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if platform == nil && strings.HasPrefix(source.Identifier, "docker-image://") {
|
||||||
|
if resolver.Platform == nil {
|
||||||
|
return nil, false, errors.New("resolver platform not configured")
|
||||||
|
}
|
||||||
|
platform, err = resolver.Platform(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var env Env
|
||||||
|
if input != nil && hasEnv(input.Env) {
|
||||||
|
env = input.Env
|
||||||
|
}
|
||||||
|
|
||||||
|
policyEval := NewPolicy(Opt{
|
||||||
|
Files: files,
|
||||||
|
Env: env,
|
||||||
|
FS: fsProvider,
|
||||||
|
VerifierProvider: resolver.VerifierProvider,
|
||||||
|
})
|
||||||
|
|
||||||
|
srcReq := &gwpb.ResolveSourceMetaResponse{
|
||||||
|
Source: source,
|
||||||
|
}
|
||||||
|
|
||||||
|
var platformPB *pb.Platform
|
||||||
|
if platform != nil {
|
||||||
|
platformPB = &pb.Platform{
|
||||||
|
Architecture: platform.Architecture,
|
||||||
|
OS: platform.OS,
|
||||||
|
Variant: platform.Variant,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
_, next, err := policyEval.CheckPolicy(ctx, &policysession.CheckPolicyRequest{
|
||||||
|
Platform: platformPB,
|
||||||
|
Source: srcReq,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if next == nil {
|
||||||
|
inp, _, err := SourceToInputWithLogger(ctx, resolver.VerifierProvider, srcReq, platform, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if hasEnv(env) {
|
||||||
|
inp.Env = env
|
||||||
|
}
|
||||||
|
if resolver.Resolve != nil && len(policyModules) > 0 {
|
||||||
|
missing := missingInputRefs(policyModules, &inp)
|
||||||
|
resolveMissing := filterResolvableMissing(missing)
|
||||||
|
if len(resolveMissing) > 0 {
|
||||||
|
req := &gwpb.ResolveSourceMetaRequest{}
|
||||||
|
if err := AddUnknowns(req, resolveMissing); err == nil && (req.Image != nil || req.Git != nil) {
|
||||||
|
resp, err := resolver.Resolve(ctx, source, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
srcReq = resp
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergeInputOverrides(inp, input), true, nil
|
||||||
|
}
|
||||||
|
if resolver.Resolve == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
resp, err := resolver.Resolve(ctx, source, next)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
srcReq = resp
|
||||||
|
}
|
||||||
|
return nil, false, errors.New("maximum attempts reached for resolving policy metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceFromInput(input *Input) (*pb.SourceOp, error) {
|
||||||
|
if input != nil && input.Image != nil {
|
||||||
|
ref := ""
|
||||||
|
switch {
|
||||||
|
case input.Image.Ref != "":
|
||||||
|
ref = input.Image.Ref
|
||||||
|
case input.Image.FullRepo != "":
|
||||||
|
ref = input.Image.FullRepo
|
||||||
|
case input.Image.Repo != "":
|
||||||
|
ref = input.Image.Repo
|
||||||
|
}
|
||||||
|
if ref != "" && input.Image.Tag != "" && !strings.Contains(ref, ":") {
|
||||||
|
ref = ref + ":" + input.Image.Tag
|
||||||
|
}
|
||||||
|
if ref != "" {
|
||||||
|
return &pb.SourceOp{Identifier: "docker-image://" + ref}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeInputOverrides(resolved Input, input *Input) *Input {
|
||||||
|
if input == nil {
|
||||||
|
return &resolved
|
||||||
|
}
|
||||||
|
if hasEnv(input.Env) {
|
||||||
|
resolved.Env = input.Env
|
||||||
|
}
|
||||||
|
return &resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasEnv(env Env) bool {
|
||||||
|
if env.Filename != "" || env.Target != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(env.Args) > 0 || len(env.Labels) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterResolvableMissing(missing []string) []string {
|
||||||
|
out := make([]string, 0, len(missing))
|
||||||
|
for _, m := range missing {
|
||||||
|
if strings.HasPrefix(m, "input.image.") || strings.HasPrefix(m, "input.git.") {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalBool(ctx context.Context, compiler *ast.Compiler, p *Policy, st *state, query string, input *Input) (bool, error) {
|
||||||
|
r := newPolicyRego(compiler, p, st, query, input)
|
||||||
|
rs, err := r.Eval(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(rs) == 0 || len(rs[0].Expressions) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
val, ok := rs[0].Expressions[0].Value.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalValue(ctx context.Context, compiler *ast.Compiler, p *Policy, st *state, query string, input *Input) (any, error) {
|
||||||
|
r := newPolicyRego(compiler, p, st, query, input)
|
||||||
|
rs, err := r.Eval(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rs) == 0 || len(rs[0].Expressions) == 0 {
|
||||||
|
return nil, errors.New("empty result")
|
||||||
|
}
|
||||||
|
return rs[0].Expressions[0].Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPolicyRego(compiler *ast.Compiler, p *Policy, st *state, query string, input *Input) *rego.Rego {
|
||||||
|
opts := []func(*rego.Rego){
|
||||||
|
rego.SetRegoVersion(ast.RegoV1),
|
||||||
|
rego.Query(query),
|
||||||
|
rego.SkipPartialNamespace(true),
|
||||||
|
rego.Compiler(compiler),
|
||||||
|
}
|
||||||
|
if input != nil {
|
||||||
|
opts = append(opts, rego.Input(input))
|
||||||
|
}
|
||||||
|
for _, f := range p.funcs {
|
||||||
|
opts = append(opts, f.impl(st))
|
||||||
|
}
|
||||||
|
return rego.New(opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modulesForPackage(modules map[string]*ast.Module, pkgPath string) []*ast.Module {
|
||||||
|
out := make([]*ast.Module, 0, len(modules))
|
||||||
|
for _, mod := range modules {
|
||||||
|
if mod.Package.Path.String() == pkgPath {
|
||||||
|
out = append(out, mod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func missingInputRefs(mods []*ast.Module, input *Input) []string {
|
||||||
|
if len(mods) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inputMap := normalizeInput(input)
|
||||||
|
refs := collectUnknowns(mods)
|
||||||
|
missing := make([]string, 0, len(refs))
|
||||||
|
for _, ref := range refs {
|
||||||
|
key := strings.TrimPrefix(ref, "input.")
|
||||||
|
if key == ref {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = trimKey(key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inputHasPath(inputMap, strings.Split(key, ".")) {
|
||||||
|
missing = append(missing, "input."+key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputMap map[string]json.RawMessage
|
||||||
|
|
||||||
|
func inputHasPath(input inputMap, path []string) bool {
|
||||||
|
if input == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cur := input
|
||||||
|
for i, p := range path {
|
||||||
|
next, ok := cur[p]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i == len(path)-1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var decoded inputMap
|
||||||
|
if err := json.Unmarshal(next, &decoded); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cur = decoded
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInput(input *Input) inputMap {
|
||||||
|
if input == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out, err := decodeJSONValue[inputMap](input)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDecision(decision any) *Decision {
|
||||||
|
obj, err := decodeJSONValue[map[string]any](decision)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var allow *bool
|
||||||
|
if v, ok := obj["allow"]; ok {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
allow = &b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
denyMsgs := []string{}
|
||||||
|
if v, ok := obj["deny_msg"]; ok {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
denyMsgs = append(denyMsgs, val)
|
||||||
|
case []any:
|
||||||
|
for _, entry := range val {
|
||||||
|
if s, ok := entry.(string); ok {
|
||||||
|
denyMsgs = append(denyMsgs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(denyMsgs) == 0 {
|
||||||
|
denyMsgs = nil
|
||||||
|
}
|
||||||
|
return &Decision{
|
||||||
|
Allow: allow,
|
||||||
|
DenyMessages: denyMsgs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONValue[T any](v any) (T, error) {
|
||||||
|
var out T
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeMetadataRequests(missing []string) []string {
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req := &gwpb.ResolveSourceMetaRequest{}
|
||||||
|
trimmed := make([]string, 0, len(missing))
|
||||||
|
for _, m := range missing {
|
||||||
|
trimmed = append(trimmed, strings.TrimPrefix(m, "input."))
|
||||||
|
}
|
||||||
|
if err := AddUnknowns(req, trimmed); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
if req.Image != nil {
|
||||||
|
out = append(out, "image")
|
||||||
|
}
|
||||||
|
if req.Git != nil {
|
||||||
|
out = append(out, "git")
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputPlatform(input *Input) (*ocispecs.Platform, error) {
|
||||||
|
if input == nil || input.Image == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if input.Image.Platform != "" {
|
||||||
|
p, err := platforms.Parse(input.Image.Platform)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "invalid platform %s", input.Image.Platform)
|
||||||
|
}
|
||||||
|
p = platforms.Normalize(p)
|
||||||
|
return &ocispecs.Platform{
|
||||||
|
OS: p.OS,
|
||||||
|
Architecture: p.Architecture,
|
||||||
|
Variant: p.Variant,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if input.Image.OS != "" || input.Image.Architecture != "" || input.Image.Variant != "" {
|
||||||
|
return &ocispecs.Platform{
|
||||||
|
OS: input.Image.OS,
|
||||||
|
Architecture: input.Image.Architecture,
|
||||||
|
Variant: input.Image.Variant,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueSortedStrings(in []string) []string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, s := range in {
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for s := range seen {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPolicyModuleLoader(fsProvider func() (fs.StatFS, func() error, error)) (func(map[string]*ast.Module) (map[string]*ast.Module, error), func() error) {
|
||||||
|
var (
|
||||||
|
root fs.StatFS
|
||||||
|
closeFS func() error
|
||||||
|
)
|
||||||
|
loader := func(resolved map[string]*ast.Module) (map[string]*ast.Module, error) {
|
||||||
|
out := make(map[string]*ast.Module)
|
||||||
|
for k, v := range resolved {
|
||||||
|
for _, imp := range v.Imports {
|
||||||
|
pv := imp.Path.Value.String()
|
||||||
|
pkgPath, ok := strings.CutPrefix(pv, "data.")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resolvedHasPackage(resolved, pkgPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn := strings.ReplaceAll(pkgPath, ".", "/") + ".rego"
|
||||||
|
if _, ok := resolved[fn]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if root == nil {
|
||||||
|
if fsProvider == nil {
|
||||||
|
return nil, errors.Errorf("no policy FS defined for import %s", pv)
|
||||||
|
}
|
||||||
|
f, cf, err := fsProvider()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "failed to get policy FS for import %s", pv)
|
||||||
|
}
|
||||||
|
root = f
|
||||||
|
closeFS = cf
|
||||||
|
}
|
||||||
|
loadName := fn
|
||||||
|
if _, err := root.Stat(loadName); err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "import %s not found for module %s", pv, k)
|
||||||
|
}
|
||||||
|
dt, err := fs.ReadFile(root, loadName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "failed to read imported policy file %s for module %s", loadName, k)
|
||||||
|
}
|
||||||
|
mod, err := ast.ParseModuleWithOpts(loadName, string(dt), ast.ParserOptions{
|
||||||
|
RegoVersion: ast.RegoV1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "failed to parse imported policy file %s for module %s", loadName, k)
|
||||||
|
}
|
||||||
|
// rewrite package to be less strict
|
||||||
|
pkgParts := strings.Split(pkgPath, ".")
|
||||||
|
ref := ast.Ref{mod.Package.Path[0]}
|
||||||
|
for _, p := range pkgParts {
|
||||||
|
ref = append(ref, ast.StringTerm(p))
|
||||||
|
}
|
||||||
|
mod.Package = &ast.Package{Path: ref}
|
||||||
|
out[fn] = mod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return loader, func() error {
|
||||||
|
if closeFS != nil {
|
||||||
|
return closeFS()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvedHasPackage(resolved map[string]*ast.Module, pkgPath string) bool {
|
||||||
|
for _, mod := range resolved {
|
||||||
|
if mod.Package != nil && mod.Package.Path.String() == pkgPath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if mod.Package != nil && strings.TrimPrefix(mod.Package.Path.String(), "data.") == pkgPath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ type Input struct {
|
|||||||
Git *Git `json:"git,omitempty"`
|
Git *Git `json:"git,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Decision struct {
|
||||||
|
Allow *bool `json:"allow,omitempty"`
|
||||||
|
DenyMessages []string `json:"deny_msg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Env struct {
|
type Env struct {
|
||||||
Args map[string]*string `json:"args,omitempty"`
|
Args map[string]*string `json:"args,omitempty"`
|
||||||
Labels map[string]string `json:"labels,omitempty"`
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func TestIntegration(t *testing.T) {
|
|||||||
tests = append(tests, policyBuildTests...)
|
tests = append(tests, policyBuildTests...)
|
||||||
tests = append(tests, policyEvalTests...)
|
tests = append(tests, policyEvalTests...)
|
||||||
tests = append(tests, policyBakeTests...)
|
tests = append(tests, policyBakeTests...)
|
||||||
|
tests = append(tests, policyTestTests...)
|
||||||
tests = append(tests, bakeTests...)
|
tests = append(tests, bakeTests...)
|
||||||
tests = append(tests, historyTests...)
|
tests = append(tests, historyTests...)
|
||||||
tests = append(tests, inspectTests...)
|
tests = append(tests, inspectTests...)
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/containerd/continuity/fs/fstest"
|
||||||
|
"github.com/moby/buildkit/util/testutil/integration"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
var policyTestTests = []func(t *testing.T, sb integration.Sandbox){
|
||||||
|
testPolicyTestRunFilter,
|
||||||
|
testPolicyTestFailMissingInput,
|
||||||
|
testPolicyTestNestedPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPolicyTestRunFilter(t *testing.T, sb integration.Sandbox) {
|
||||||
|
skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+")
|
||||||
|
dir := tmpdir(
|
||||||
|
t,
|
||||||
|
fstest.CreateFile("policy.rego", []byte(`
|
||||||
|
package docker
|
||||||
|
|
||||||
|
default allow = false
|
||||||
|
|
||||||
|
allow if input.image.repo == "example/allowlist"
|
||||||
|
|
||||||
|
allow if {
|
||||||
|
input.image.repo == "example/docs"
|
||||||
|
input.image.tag == "doc"
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_msg[msg] if {
|
||||||
|
not allow
|
||||||
|
msg := "repository not allowed"
|
||||||
|
}
|
||||||
|
|
||||||
|
decision := {"allow": allow, "deny_msg": deny_msg}
|
||||||
|
`), 0600),
|
||||||
|
fstest.CreateFile("policy_test.rego", []byte(`
|
||||||
|
package docker
|
||||||
|
|
||||||
|
# Images from allowlisted repo are allowed
|
||||||
|
test_allowlisted_repo if {
|
||||||
|
result := data.docker.decision with input as {"image": {"repo": "example/allowlist"}}
|
||||||
|
result.allow
|
||||||
|
count(result.deny_msg) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Other repos are denied
|
||||||
|
test_non_allowlisted_repo if {
|
||||||
|
result := data.docker.decision with input as {"image": {"repo": "example/blocked"}}
|
||||||
|
not result.allow
|
||||||
|
result.deny_msg["repository not allowed"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Docs images are allowed from a specific repo
|
||||||
|
test_docs_tag_allowed if {
|
||||||
|
result := data.docker.decision with input as {"image": {"repo": "example/docs", "tag": "doc"}}
|
||||||
|
result.allow
|
||||||
|
}
|
||||||
|
`), 0600),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := buildxCmd(sb, withDir(dir), withArgs(
|
||||||
|
"policy",
|
||||||
|
"test",
|
||||||
|
"--filename",
|
||||||
|
"policy",
|
||||||
|
".",
|
||||||
|
))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, string(out))
|
||||||
|
require.Contains(t, string(out), "test_allowlisted_repo: PASS")
|
||||||
|
require.Contains(t, string(out), "test_non_allowlisted_repo: PASS")
|
||||||
|
require.Contains(t, string(out), "test_docs_tag_allowed: PASS")
|
||||||
|
|
||||||
|
cmd = buildxCmd(sb, withDir(dir), withArgs(
|
||||||
|
"policy",
|
||||||
|
"test",
|
||||||
|
"--filename",
|
||||||
|
"policy",
|
||||||
|
"--run",
|
||||||
|
"test_allowlisted_repo",
|
||||||
|
".",
|
||||||
|
))
|
||||||
|
out, err = cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, string(out))
|
||||||
|
require.Contains(t, string(out), "test_allowlisted_repo: PASS")
|
||||||
|
require.NotContains(t, string(out), "test_guest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPolicyTestFailMissingInput(t *testing.T, sb integration.Sandbox) {
|
||||||
|
skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+")
|
||||||
|
dir := tmpdir(
|
||||||
|
t,
|
||||||
|
fstest.CreateFile("policy.rego", []byte(`
|
||||||
|
package docker
|
||||||
|
|
||||||
|
default allow = false
|
||||||
|
|
||||||
|
allow if input.image.repo == "example/allowlist"
|
||||||
|
|
||||||
|
deny_msg[msg] if {
|
||||||
|
not allow
|
||||||
|
msg := "repository required"
|
||||||
|
}
|
||||||
|
decision := {"allow": allow, "deny_msg": deny_msg}
|
||||||
|
`), 0600),
|
||||||
|
fstest.CreateFile("policy_test.rego", []byte(`
|
||||||
|
package docker
|
||||||
|
|
||||||
|
test_missing_repo if {
|
||||||
|
result := data.docker.decision with input as {"image": {}}
|
||||||
|
result.allow
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allowlisted_ok if {
|
||||||
|
result := data.docker.decision with input as {"image": {"repo": "example/allowlist"}}
|
||||||
|
result.allow
|
||||||
|
}
|
||||||
|
`), 0600),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := buildxCmd(sb, withDir(dir), withArgs(
|
||||||
|
"policy",
|
||||||
|
"test",
|
||||||
|
"--filename",
|
||||||
|
"policy",
|
||||||
|
".",
|
||||||
|
))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
require.Error(t, err, string(out))
|
||||||
|
require.Contains(t, string(out), "test_missing_repo: FAIL")
|
||||||
|
require.Contains(t, string(out), "test_allowlisted_ok: PASS")
|
||||||
|
require.Contains(t, string(out), "missing_input: input.image.repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPolicyTestNestedPath(t *testing.T, sb integration.Sandbox) {
|
||||||
|
skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+")
|
||||||
|
dir := tmpdir(
|
||||||
|
t,
|
||||||
|
fstest.CreateFile("Dockerfile.rego", []byte(`
|
||||||
|
package docker
|
||||||
|
|
||||||
|
default allow = false
|
||||||
|
|
||||||
|
allow if input.image.repo == "example/allowlist"
|
||||||
|
|
||||||
|
decision := {"allow": allow}
|
||||||
|
`), 0600),
|
||||||
|
fstest.CreateDir("scripts", 0700),
|
||||||
|
fstest.CreateFile("scripts/policy_test.rego", []byte(`
|
||||||
|
package docker
|
||||||
|
|
||||||
|
test_allowlisted_repo if {
|
||||||
|
result := data.docker.decision with input as {"image": {"repo": "example/allowlist"}}
|
||||||
|
result.allow
|
||||||
|
}
|
||||||
|
`), 0600),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := buildxCmd(sb, withDir(dir), withArgs(
|
||||||
|
"policy",
|
||||||
|
"test",
|
||||||
|
"--filename",
|
||||||
|
"Dockerfile",
|
||||||
|
"scripts/policy_test.rego",
|
||||||
|
))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, string(out))
|
||||||
|
require.Contains(t, string(out), "test_allowlisted_repo: PASS")
|
||||||
|
|
||||||
|
cmd = buildxCmd(sb, withDir(dir), withArgs(
|
||||||
|
"policy",
|
||||||
|
"test",
|
||||||
|
"--filename",
|
||||||
|
"Dockerfile",
|
||||||
|
"scripts",
|
||||||
|
))
|
||||||
|
out, err = cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, string(out))
|
||||||
|
require.Contains(t, string(out), "test_allowlisted_repo: PASS")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user