commands: implement policy eval command
Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
+361
-16
@@ -1,47 +1,392 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/buildx/builder"
|
||||
"github.com/docker/buildx/policy"
|
||||
"github.com/docker/buildx/util/confutil"
|
||||
"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"
|
||||
)
|
||||
|
||||
func evalCmd() *cobra.Command {
|
||||
var filename string
|
||||
var printOutput bool
|
||||
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 [source]",
|
||||
Use: "eval source",
|
||||
Short: "Evaluate policy for a source",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runEval(args, filename, printOutput)
|
||||
opts.builder = rootOpts.Builder
|
||||
return runEval(cmd.Context(), dockerCli, args[0], opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&filename, "filename", "", "Policy filename to evaluate")
|
||||
cmd.Flags().BoolVar(&printOutput, "print", false, "Print policy output")
|
||||
|
||||
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(args []string, filename string, printOutput bool) error {
|
||||
if len(args) > 0 {
|
||||
if _, err := parseSource(args[0]); err != nil {
|
||||
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,
|
||||
}
|
||||
openClient, release, err := gatewayClientFactory(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
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
|
||||
}
|
||||
gwClient, err := openClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opt := sourceResolverOpt(req, &p)
|
||||
resp, err := gwClient.ResolveSourceMetadata(ctx, src, opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcReq = buildSourceMetaResponse(resp, req)
|
||||
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,
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
gwClient, err := openClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opt := sourceResolverOpt(next, &p)
|
||||
resp, err := gwClient.ResolveSourceMetadata(ctx, src, opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcReq = buildSourceMetaResponse(resp, next)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
Platform: platform,
|
||||
ResolveMode: req.ResolveMode,
|
||||
}
|
||||
}
|
||||
if req.Git != nil {
|
||||
opt.GitOpt = &sourceresolver.ResolveGitOpt{
|
||||
ReturnObject: req.Git.ReturnObject,
|
||||
}
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
func buildSourceMetaResponse(resp *sourceresolver.MetaResponse, req *gwpb.ResolveSourceMetaRequest) *gwpb.ResolveSourceMetaResponse {
|
||||
out := &gwpb.ResolveSourceMetaResponse{
|
||||
Source: resp.Op,
|
||||
}
|
||||
if resp.Image != nil {
|
||||
chain := toGatewayAttestationChain(resp.Image.AttestationChain)
|
||||
if chain == nil && req != nil && req.Image != nil && req.Image.AttestationChain {
|
||||
chain = &gwpb.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)
|
||||
}
|
||||
_ = filename
|
||||
_ = printOutput
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func parseSource(input string) (*pb.SourceOp, error) {
|
||||
if strings.HasPrefix(input, "docker-image://") {
|
||||
return &pb.SourceOp{Identifier: input}, nil
|
||||
refstr := strings.TrimPrefix(input, "docker-image://")
|
||||
ref, err := reference.ParseNormalizedNamed(refstr)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to parse image source reference")
|
||||
}
|
||||
return &pb.SourceOp{Identifier: "docker-image://" + ref.String()}, nil
|
||||
}
|
||||
if strings.HasPrefix(input, "git://") {
|
||||
_, ok, err := dockerui.DetectGitContext(input, nil)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/moby/buildkit/client"
|
||||
gwclient "github.com/moby/buildkit/frontend/gateway/client"
|
||||
)
|
||||
|
||||
type gatewayClientOpener func(context.Context) (gwclient.Client, error)
|
||||
|
||||
func gatewayClientFactory(c *client.Client) (gatewayClientOpener, func() error, error) {
|
||||
var (
|
||||
once sync.Once
|
||||
releaseOnce sync.Once
|
||||
started atomic.Bool
|
||||
ready = make(chan gwclient.Client, 1)
|
||||
done = make(chan error, 1)
|
||||
openErr error
|
||||
releaseErr error
|
||||
gwClient gwclient.Client
|
||||
cancel context.CancelCauseFunc
|
||||
)
|
||||
|
||||
open := func(ctx context.Context) (gwclient.Client, error) {
|
||||
once.Do(func() {
|
||||
started.Store(true)
|
||||
buildCtx, cancelFn := context.WithCancelCause(ctx)
|
||||
cancel = cancelFn
|
||||
|
||||
go func() {
|
||||
_, err := c.Build(buildCtx, client.SolveOpt{Internal: true}, "buildx", func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
|
||||
ready <- c
|
||||
<-buildCtx.Done()
|
||||
return nil, context.Cause(buildCtx)
|
||||
}, nil)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case gwClient = <-ready:
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
err = errors.New("gateway build finished without a client")
|
||||
}
|
||||
openErr = err
|
||||
case <-ctx.Done():
|
||||
openErr = context.Cause(ctx)
|
||||
cancelFn(openErr)
|
||||
}
|
||||
})
|
||||
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
return gwClient, nil
|
||||
}
|
||||
|
||||
release := func() error {
|
||||
releaseOnce.Do(func() {
|
||||
if !started.Load() {
|
||||
return
|
||||
}
|
||||
if cancel != nil {
|
||||
cancel(context.Canceled)
|
||||
}
|
||||
err := <-done
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
releaseErr = err
|
||||
})
|
||||
return releaseErr
|
||||
}
|
||||
|
||||
return open, release, nil
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type RootOptions struct {
|
||||
Builder *string
|
||||
}
|
||||
|
||||
// RootCmd creates the policy command tree.
|
||||
func RootCmd(rootcmd *cobra.Command) *cobra.Command {
|
||||
func RootCmd(rootcmd *cobra.Command, dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "policy",
|
||||
Short: "Commands for working with build policies",
|
||||
@@ -13,7 +18,7 @@ func RootCmd(rootcmd *cobra.Command) *cobra.Command {
|
||||
|
||||
cmd.AddCommand(
|
||||
jsonSchemaCmd(),
|
||||
evalCmd(),
|
||||
evalCmd(dockerCli, rootOpts),
|
||||
testCmd(),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user