Files
buildx/commands/policy/eval.go
T
Tonis Tiigi dce5289722 policy: separate sourcemetaresolver utility to reusable pkg
Turn the current utility used by policy helper commands that
allows getting gateway.Client lazily into reusable package.

This is preparation for using this utility also in the policy
verification, outside of commands.

Only expose SourceMetaResolver as other methods of gateway client
are not needed atm by any callers.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
2026-02-13 13:59:33 -08:00

412 lines
11 KiB
Go

package policy
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/buildx/builder"
"github.com/docker/buildx/policy"
"github.com/docker/buildx/util/confutil"
"github.com/docker/buildx/util/sourcemeta"
"github.com/docker/cli/cli/command"
"github.com/moby/buildkit/client/llb/sourceresolver"
"github.com/moby/buildkit/frontend/dockerui"
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
"github.com/moby/buildkit/solver/pb"
spb "github.com/moby/buildkit/sourcepolicy/pb"
"github.com/moby/buildkit/sourcepolicy/policysession"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/timestamppb"
)
type evalOpts struct {
filename string
printOutput bool
fields []string
builder *string
}
func evalCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
var opts evalOpts
cmd := &cobra.Command{
Use: "eval [OPTIONS] source",
Short: "Evaluate policy for a source",
Args: cobra.ExactArgs(1),
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, args []string) error {
opts.builder = rootOpts.Builder
return runEval(cmd.Context(), dockerCli, args[0], opts)
},
}
cmd.Flags().StringVar(&opts.filename, "filename", "Dockerfile", "Policy filename to evaluate")
cmd.Flags().BoolVar(&opts.printOutput, "print", false, "Print policy output")
cmd.Flags().StringSliceVar(&opts.fields, "fields", nil, "Fields to evaluate")
return cmd
}
func runEval(ctx context.Context, dockerCli command.Cli, source string, opts evalOpts) error {
src, err := parseSource(source)
if err != nil {
return err
}
bopts := []builder.Option{}
if opts.builder != nil {
bopts = append(bopts, builder.WithName(*opts.builder))
}
b, err := builder.New(dockerCli, bopts...)
if err != nil {
return err
}
nodes, err := b.LoadNodes(ctx)
if err != nil {
return err
}
c, err := nodes[0].Driver.Client(ctx)
if err != nil {
return err
}
workers, err := c.ListWorkers(ctx)
if err != nil {
return err
}
if len(workers) == 0 {
return errors.New("no workers available in the builder")
}
defaultPlatform := workers[0].Platforms[0]
p := ocispecs.Platform{
Architecture: defaultPlatform.Architecture,
OS: defaultPlatform.OS,
Variant: defaultPlatform.Variant,
}
metaResolver := sourcemeta.NewResolver(c)
defer metaResolver.Close()
platform := &pb.Platform{
Architecture: p.Architecture,
OS: p.OS,
Variant: p.Variant,
}
verifier := policy.SignatureVerifier(confutil.NewConfig(dockerCli))
if opts.printOutput {
srcReq := &gwpb.ResolveSourceMetaResponse{
Source: src,
}
maxAttempts := 5
var unknowns []string
var lastUnknowns []string
var trimmedUnknowns []string
var input policy.Input
var doneInvalidCheck bool
var invalidFields []string
for {
maxAttempts--
if maxAttempts <= 0 {
return errors.New("maximum attempts reached for resolving source metadata")
}
input, unknowns, err = policy.SourceToInput(ctx, verifier, srcReq, &p)
if err != nil {
return err
}
trimmedUnknowns = trimInputPrefixSlice(unknowns)
if lastUnknowns != nil && slices.Equal(trimmedUnknowns, lastUnknowns) {
break
}
lastUnknowns = slices.Clone(trimmedUnknowns)
toReload := []string{}
for _, f := range opts.fields {
if slices.Contains(trimmedUnknowns, f) {
toReload = append(toReload, f)
} else if !doneInvalidCheck {
invalidFields = append(invalidFields, f)
}
}
doneInvalidCheck = true
if len(toReload) > 0 {
req := &gwpb.ResolveSourceMetaRequest{}
if err := policy.AddUnknowns(req, toReload); err != nil {
return err
}
opt := sourceResolverOpt(req, &p)
resp, err := metaResolver.ResolveSourceMetadata(ctx, src, opt)
if err != nil {
return err
}
srcReq = buildSourceMetaResponse(resp)
continue
}
break
}
if len(invalidFields) > 0 {
logrus.Warnf("invalid fields: %v", strings.Join(invalidFields, ", "))
}
if len(trimmedUnknowns) > 0 {
logrus.Infof("unresolved fields: %v", strings.Join(trimmedUnknowns, ", "))
}
dt, err := json.MarshalIndent(input, "", " ")
if err != nil {
return errors.Wrap(err, "failed to marshal policy input")
}
_, _ = fmt.Fprintln(os.Stdout, string(dt))
return nil
}
if opts.filename == "" {
return errors.New("filename is required")
}
policyName := opts.filename
policyFile := policyName + ".rego"
policyData, err := os.ReadFile(policyFile)
if err != nil {
return errors.Wrapf(err, "failed to read policy file %s", policyFile)
}
fsProvider := func() (fs.StatFS, func() error, error) {
root, err := os.OpenRoot(".")
if err != nil {
return nil, nil, errors.Wrapf(err, "failed to open root for policy file %s", policyFile)
}
baseFS := root.FS()
statFS, ok := baseFS.(fs.StatFS)
if !ok {
_ = root.Close()
return nil, nil, errors.Errorf("invalid root FS type %T", baseFS)
}
return statFS, root.Close, nil
}
env := policy.Env{
Filename: filepath.Base(policyName),
}
policyEval := policy.NewPolicy(policy.Opt{
Files: []policy.File{
{
Filename: filepath.Base(policyFile),
Data: policyData,
},
},
Env: env,
FS: fsProvider,
VerifierProvider: verifier,
DefaultPlatform: &p,
})
srcReq := &gwpb.ResolveSourceMetaResponse{
Source: src,
}
maxAttempts := 5
for {
maxAttempts--
if maxAttempts <= 0 {
return errors.New("maximum attempts reached for resolving policy metadata")
}
decision, next, err := policyEval.CheckPolicy(ctx, &policysession.CheckPolicyRequest{
Platform: platform,
Source: srcReq,
})
if err != nil {
return err
}
if next == nil {
return evalDecisionError(decision)
}
opt := sourceResolverOpt(next, &p)
resp, err := metaResolver.ResolveSourceMetadata(ctx, src, opt)
if err != nil {
return err
}
srcReq = buildSourceMetaResponse(resp)
}
}
func toGatewayDescriptor(desc ocispecs.Descriptor) *gwpb.Descriptor {
return &gwpb.Descriptor{
MediaType: desc.MediaType,
Digest: desc.Digest.String(),
Size: desc.Size,
Annotations: desc.Annotations,
}
}
func toGatewayAttestationChain(chain *sourceresolver.AttestationChain) *gwpb.AttestationChain {
if chain == nil {
return nil
}
signatures := make([]string, 0, len(chain.SignatureManifests))
for _, dgst := range chain.SignatureManifests {
signatures = append(signatures, dgst.String())
}
blobs := make(map[string]*gwpb.Blob, len(chain.Blobs))
for dgst, blob := range chain.Blobs {
blobs[dgst.String()] = &gwpb.Blob{
Descriptor_: toGatewayDescriptor(blob.Descriptor),
Data: blob.Data,
}
}
return &gwpb.AttestationChain{
Root: chain.Root.String(),
ImageManifest: chain.ImageManifest.String(),
AttestationManifest: chain.AttestationManifest.String(),
SignatureManifests: signatures,
Blobs: blobs,
}
}
func sourceResolverOpt(req *gwpb.ResolveSourceMetaRequest, platform *ocispecs.Platform) sourceresolver.Opt {
opt := sourceresolver.Opt{
LogName: req.LogName,
SourcePolicies: req.SourcePolicies,
}
if req.Image != nil {
opt.ImageOpt = &sourceresolver.ResolveImageOpt{
NoConfig: req.Image.NoConfig,
AttestationChain: req.Image.AttestationChain,
ResolveAttestations: slices.Clone(req.Image.ResolveAttestations),
Platform: platform,
ResolveMode: req.ResolveMode,
}
}
if req.Git != nil {
opt.GitOpt = &sourceresolver.ResolveGitOpt{
ReturnObject: req.Git.ReturnObject,
}
}
return opt
}
func buildSourceMetaResponse(resp *sourceresolver.MetaResponse) *gwpb.ResolveSourceMetaResponse {
out := &gwpb.ResolveSourceMetaResponse{
Source: resp.Op,
}
if resp.Image != nil {
chain := toGatewayAttestationChain(resp.Image.AttestationChain)
out.Image = &gwpb.ResolveSourceImageResponse{
Digest: resp.Image.Digest.String(),
Config: resp.Image.Config,
AttestationChain: chain,
}
}
if resp.Git != nil {
out.Git = &gwpb.ResolveSourceGitResponse{
Checksum: resp.Git.Checksum,
Ref: resp.Git.Ref,
CommitChecksum: resp.Git.CommitChecksum,
CommitObject: resp.Git.CommitObject,
TagObject: resp.Git.TagObject,
}
}
if resp.HTTP != nil {
var lastModified *timestamppb.Timestamp
if resp.HTTP.LastModified != nil {
lastModified = timestamppb.New(*resp.HTTP.LastModified)
}
out.HTTP = &gwpb.ResolveSourceHTTPResponse{
Checksum: resp.HTTP.Digest.String(),
Filename: resp.HTTP.Filename,
LastModified: lastModified,
}
}
return out
}
func trimInputPrefixSlice(fields []string) []string {
if len(fields) == 0 {
return fields
}
out := make([]string, 0, len(fields))
for _, field := range fields {
out = append(out, strings.TrimPrefix(field, "input."))
}
return out
}
func evalDecisionError(decision *policysession.DecisionResponse) error {
if decision == nil {
return errors.New("policy returned no decision")
}
switch decision.Action {
case spb.PolicyAction_ALLOW, spb.PolicyAction_CONVERT:
return nil
case spb.PolicyAction_DENY:
if len(decision.DenyMessages) == 0 {
return errors.New("policy denied")
}
msgs := make([]string, 0, len(decision.DenyMessages))
for _, msg := range decision.DenyMessages {
if msg != nil && msg.Message != "" {
msgs = append(msgs, msg.Message)
}
}
if len(msgs) == 0 {
return errors.New("policy denied")
}
return errors.Errorf("policy denied: %s", strings.Join(msgs, "; "))
default:
return errors.Errorf("unknown policy action %s", decision.Action)
}
}
func parseSource(input string) (*pb.SourceOp, error) {
if refstr, ok := strings.CutPrefix(input, "docker-image://"); ok {
ref, err := reference.ParseNormalizedNamed(refstr)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse image source reference")
}
ref = reference.TagNameOnly(ref)
return &pb.SourceOp{Identifier: "docker-image://" + ref.String()}, nil
}
if strings.HasPrefix(input, "git://") {
_, ok, err := dockerui.DetectGitContext(input, nil)
if !ok {
return nil, errors.Errorf("invalid git context %s", input)
}
if err != nil {
return nil, err
}
return &pb.SourceOp{Identifier: input}, nil
}
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
_, ok, err := dockerui.DetectGitContext(input, nil)
if ok {
return &pb.SourceOp{
Identifier: "git://" + input,
Attrs: map[string]string{
"git.fullurl": input,
},
}, nil
}
if err != nil && !errors.Is(err, errdefs.ErrInvalidArgument) {
return nil, err
}
return &pb.SourceOp{Identifier: input}, nil
}
// everything else is treated as a local path
if _, err := os.Stat(input); err != nil {
return nil, errors.Wrapf(err, "invalid local path %s", input)
}
return &pb.SourceOp{Identifier: "local://context"}, nil
}