commands: implement policy test command

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2026-01-14 10:31:12 -08:00
parent 32b6b75478
commit c6ecdc08cf
7 changed files with 1225 additions and 11 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ func RootCmd(rootcmd *cobra.Command, dockerCli command.Cli, rootOpts RootOptions
cmd.AddCommand(
jsonSchemaCmd(),
evalCmd(dockerCli, rootOpts),
testCmd(),
testCmd(dockerCli, rootOpts),
)
return cmd
+193 -5
View File
@@ -1,24 +1,212 @@
package policy
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/spf13/cobra"
)
func testCmd() *cobra.Command {
func testCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
var opts policy.TestOptions
cmd := &cobra.Command{
Use: "test <path>",
Short: "Run policy tests",
Args: cobra.ExactArgs(1),
DisableFlagsInUseLine: true,
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
}
func runTest(path string) error {
_ = path
return errors.New("not implemented")
func runTest(ctx context.Context, out io.Writer, path string, opts policy.TestOptions) error {
root := os.DirFS(".")
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
}
+6 -5
View File
@@ -5,11 +5,12 @@ Run policy tests
### Options
| Name | Type | Default | Description |
|:----------------|:---------|:--------|:-----------------------------------------|
| `--builder` | `string` | | Override the configured builder instance |
| `-D`, `--debug` | `bool` | | Enable debug logging |
| Name | Type | Default | Description |
|:----------------|:---------|:-------------|:---------------------------------------------------|
| `--builder` | `string` | | Override the configured builder instance |
| `-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-->
+835
View File
@@ -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
}
+5
View File
@@ -15,6 +15,11 @@ type Input struct {
Git *Git `json:"git,omitempty"`
}
type Decision struct {
Allow *bool `json:"allow,omitempty"`
DenyMessages []string `json:"deny_msg,omitempty"`
}
type Env struct {
Args map[string]*string `json:"args,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
+1
View File
@@ -26,6 +26,7 @@ func TestIntegration(t *testing.T) {
tests = append(tests, policyBuildTests...)
tests = append(tests, policyEvalTests...)
tests = append(tests, policyBakeTests...)
tests = append(tests, policyTestTests...)
tests = append(tests, bakeTests...)
tests = append(tests, historyTests...)
tests = append(tests, inspectTests...)
+184
View File
@@ -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")
}