From 4af8cdb6af448dd51bedc7111cc7dc4ad7cfd915 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Tue, 6 Jan 2026 18:53:54 -0800 Subject: [PATCH] build: add --policy flag for controlling policy configuration Signed-off-by: Tonis Tiigi --- build/build.go | 85 ++++++++++++++++++++++++- build/policy_test.go | 143 +++++++++++++++++++++++++++++++++++++++++++ commands/build.go | 70 +++++++++++++++++++++ 3 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 build/policy_test.go diff --git a/build/build.go b/build/build.go index f272cb219..09638f4c5 100644 --- a/build/build.go +++ b/build/build.go @@ -97,6 +97,15 @@ type Options struct { SourcePolicy *spb.Policy GroupRef string Annotations map[exptypes.AnnotationKey]string // Not used during build, annotations are already set in Exports. Just used to check for support with drivers. + Policy []PolicyConfig +} + +type PolicyConfig struct { + Files []policy.File + Reset bool + Disabled bool + Strict *bool + LogLevel *logrus.Level } type CallFunc struct { @@ -120,8 +129,80 @@ type Inputs struct { } type policyOpt struct { - Files []policy.File - FS func() (fs.StatFS, func() error, error) + Files []policy.File + FS func() (fs.StatFS, func() error, error) + Strict bool + LogLevel logrus.Level +} + +func withPolicyConfig(defaultPolicy policyOpt, configs []PolicyConfig) ([]policyOpt, error) { + if len(configs) == 0 { + if len(defaultPolicy.Files) == 0 { + return nil, nil + } + return []policyOpt{defaultPolicy}, nil + } + + for _, cfg := range configs { + if !cfg.Disabled { + continue + } + if cfg.Reset || cfg.Strict != nil || cfg.LogLevel != nil || len(cfg.Files) > 0 { + return nil, errors.New("disabled policy cannot be combined with other policy flags") + } + if len(configs) > 1 { + return nil, errors.New("disabled policy cannot be combined with other policy flags") + } + return nil, nil + } + + out := make([]policyOpt, 0, len(configs)+1) + if len(defaultPolicy.Files) != 0 { + out = append(out, defaultPolicy) + } + + var last PolicyConfig + + for _, cfg := range configs { + if cfg.Reset { + out = nil + } + + if len(cfg.Files) == 0 { + if len(out) == 0 { + last = cfg + } else { + last := &out[len(out)-1] + if cfg.Strict != nil { + last.Strict = *cfg.Strict + } + if cfg.LogLevel != nil { + last.LogLevel = *cfg.LogLevel + } + } + continue + } + + opt := policyOpt{ + Files: cfg.Files, + } + if last.Strict != nil { + opt.Strict = *last.Strict + } + if last.LogLevel != nil { + opt.LogLevel = *last.LogLevel + } + if cfg.Strict != nil { + opt.Strict = *cfg.Strict + } + if cfg.LogLevel != nil { + opt.LogLevel = *cfg.LogLevel + } + opt.FS = defaultPolicy.FS + out = append(out, opt) + } + + return out, nil } type NamedContext struct { diff --git a/build/policy_test.go b/build/policy_test.go new file mode 100644 index 000000000..66fdd39e3 --- /dev/null +++ b/build/policy_test.go @@ -0,0 +1,143 @@ +package build + +import ( + "io/fs" + "testing" + + "github.com/docker/buildx/policy" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +func boolPtr(v bool) *bool { + return &v +} + +func levelPtr(v logrus.Level) *logrus.Level { + return &v +} + +// TestWithPolicyConfigDefaults ensures default policy is returned when no configs are provided. +func TestWithPolicyConfigDefaults(t *testing.T) { + defaultPolicy := policyOpt{ + Files: []policy.File{ + {Filename: "default.rego", Data: []byte("package policy")}, + }, + FS: func() (fs.StatFS, func() error, error) { + return nil, nil, nil + }, + } + + out, err := withPolicyConfig(defaultPolicy, nil) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, defaultPolicy.Files, out[0].Files) + require.False(t, out[0].Strict) + require.Equal(t, logrus.Level(0), out[0].LogLevel) + require.NotNil(t, out[0].FS) +} + +// TestWithPolicyConfigDisabled validates disabled policy behavior across invalid and valid combinations. +func TestWithPolicyConfigDisabled(t *testing.T) { + _, err := withPolicyConfig(policyOpt{}, []PolicyConfig{ + {Disabled: true, Files: []policy.File{{Filename: "x.rego"}}}, + }) + require.Error(t, err) + + _, err = withPolicyConfig(policyOpt{}, []PolicyConfig{ + {Disabled: true, Reset: true}, + }) + require.Error(t, err) + + _, err = withPolicyConfig(policyOpt{}, []PolicyConfig{ + {Disabled: true, Strict: boolPtr(true)}, + }) + require.Error(t, err) + + out, err := withPolicyConfig(policyOpt{}, []PolicyConfig{ + {Disabled: true, LogLevel: levelPtr(logrus.WarnLevel)}, + }) + require.NoError(t, err) + require.Nil(t, out) + + _, err = withPolicyConfig(policyOpt{}, []PolicyConfig{ + {Disabled: true}, + {}, + }) + require.Error(t, err) + + out, err = withPolicyConfig(policyOpt{}, []PolicyConfig{ + {Disabled: true}, + }) + require.NoError(t, err) + require.Nil(t, out) +} + +// TestWithPolicyConfigResetAndFiles ensures reset drops defaults and uses explicitly provided files. +func TestWithPolicyConfigResetAndFiles(t *testing.T) { + defaultPolicy := policyOpt{ + Files: []policy.File{{Filename: "default.rego"}}, + FS: func() (fs.StatFS, func() error, error) { + return nil, nil, nil + }, + } + + out, err := withPolicyConfig(defaultPolicy, []PolicyConfig{ + {Reset: true}, + {Files: []policy.File{{Filename: "a.rego"}}}, + }) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, "a.rego", out[0].Files[0].Filename) + require.NotNil(t, out[0].FS) +} + +// TestWithPolicyConfigStrictAndLogLevel ensures strict and log level apply to existing policy. +func TestWithPolicyConfigStrictAndLogLevel(t *testing.T) { + defaultPolicy := policyOpt{ + Files: []policy.File{{Filename: "default.rego"}}, + } + + out, err := withPolicyConfig(defaultPolicy, []PolicyConfig{ + {Strict: boolPtr(true), LogLevel: levelPtr(logrus.WarnLevel)}, + }) + require.NoError(t, err) + require.Len(t, out, 1) + require.True(t, out[0].Strict) + require.Equal(t, logrus.WarnLevel, out[0].LogLevel) +} + +// TestWithPolicyConfigStrictIgnoredWithoutPolicy ensures strict without any policy produces no entries. +func TestWithPolicyConfigStrictIgnoredWithoutPolicy(t *testing.T) { + out, err := withPolicyConfig(policyOpt{}, []PolicyConfig{ + {Strict: boolPtr(true)}, + }) + require.NoError(t, err) + require.Len(t, out, 0) +} + +// TestWithPolicyConfigMultipleFilesAndOverrides ensures per-entry overrides and carryover apply across multiple files. +func TestWithPolicyConfigMultipleFilesAndOverrides(t *testing.T) { + defaultPolicy := policyOpt{ + Files: []policy.File{{Filename: "default.rego"}}, + FS: func() (fs.StatFS, func() error, error) { + return nil, nil, nil + }, + } + + out, err := withPolicyConfig(defaultPolicy, []PolicyConfig{ + {Files: []policy.File{{Filename: "a.rego"}}}, + {Strict: boolPtr(true), LogLevel: levelPtr(logrus.WarnLevel)}, + {Files: []policy.File{{Filename: "b.rego"}}, Strict: boolPtr(true)}, + }) + require.NoError(t, err) + require.Len(t, out, 3) + require.Equal(t, "default.rego", out[0].Files[0].Filename) + require.Equal(t, "a.rego", out[1].Files[0].Filename) + require.True(t, out[1].Strict) + require.Equal(t, logrus.WarnLevel, out[1].LogLevel) + require.Equal(t, "b.rego", out[2].Files[0].Filename) + require.True(t, out[2].Strict) + require.NotNil(t, out[1].FS) + require.NotNil(t, out[2].FS) +} diff --git a/commands/build.go b/commands/build.go index afe329d8b..85e5fa0d5 100644 --- a/commands/build.go +++ b/commands/build.go @@ -20,6 +20,7 @@ import ( "github.com/containerd/console" "github.com/docker/buildx/build" "github.com/docker/buildx/builder" + "github.com/docker/buildx/policy" "github.com/docker/buildx/store" "github.com/docker/buildx/store/storeutil" "github.com/docker/buildx/util/buildflags" @@ -56,6 +57,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/tonistiigi/go-csvvalue" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "google.golang.org/grpc/codes" @@ -79,6 +81,7 @@ type buildOptions struct { noCacheFilter []string outputs []string platforms []string + policy []string callFunc string secrets []string shmSize dockeropts.MemBytes @@ -150,6 +153,11 @@ func (o *buildOptions) toOptions() (*BuildOptions, error) { return nil, err } + opts.Policy, err = parsePolicyConfigs(o.policy) + if err != nil { + return nil, err + } + inAttests := slices.Clone(o.attests) if o.provenance != "" { inAttests = append(inAttests, buildflags.CanonicalizeAttest("provenance", o.provenance)) @@ -228,6 +236,64 @@ func (o *buildOptions) toDisplayMode() (progressui.DisplayMode, error) { return progress, nil } +func parsePolicyConfigs(in []string) ([]build.PolicyConfig, error) { + if len(in) == 0 { + return nil, nil + } + + out := make([]build.PolicyConfig, 0, len(in)) + for _, s := range in { + fields, err := csvvalue.Fields(s, nil) + if err != nil { + return nil, err + } + + cfg := build.PolicyConfig{} + for _, field := range fields { + key, value, ok := strings.Cut(field, "=") + if !ok { + return nil, errors.Errorf("invalid value %s", field) + } + key = strings.TrimSpace(strings.ToLower(key)) + switch key { + case "filename": + if value == "" { + return nil, errors.Errorf("invalid value %s", field) + } + cfg.Files = append(cfg.Files, policy.File{Filename: value}) + case "reset": + b, err := strconv.ParseBool(value) + if err != nil { + return nil, errors.Wrapf(err, "invalid value %s", field) + } + cfg.Reset = b + case "disabled": + b, err := strconv.ParseBool(value) + if err != nil { + return nil, errors.Wrapf(err, "invalid value %s", field) + } + cfg.Disabled = b + case "strict": + b, err := strconv.ParseBool(value) + if err != nil { + return nil, errors.Wrapf(err, "invalid value %s", field) + } + cfg.Strict = &b + case "log-level": + lvl, err := logrus.ParseLevel(value) + if err != nil { + return nil, errors.Wrapf(err, "invalid value %s", field) + } + cfg.LogLevel = &lvl + default: + return nil, errors.Errorf("invalid value %s", field) + } + } + out = append(out, cfg) + } + return out, nil +} + const ( commandNameAttribute = attribute.Key("command.name") commandOptionsHash = attribute.Key("command.options.hash") @@ -542,6 +608,8 @@ func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugger debuggerOpt flags.StringArrayVar(&options.platforms, "platform", platformsDefault, "Set target platform for build") + flags.StringArrayVar(&options.policy, "policy", []string{}, `Policy configuration (format: "filename=path[,filename=path][,reset=true|false][,disabled=true|false][,strict=true|false][,log-level=level]")`) + flags.BoolVar(&options.exportPush, "push", false, `Shorthand for "--output=type=registry,unpack=false"`) flags.BoolVarP(&options.quiet, "quiet", "q", false, "Suppress the build output and print image ID on success") @@ -976,6 +1044,7 @@ type BuildOptions struct { GroupRef string Annotations []string ProvenanceResponseMode string + Policy []build.PolicyConfig } // RunBuild runs the specified build and returns the result. @@ -1103,6 +1172,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *BuildOptions, inSt opts.Attests = in.Attests.ToMap() opts.SourcePolicy = in.SourcePolicy + opts.Policy = in.Policy allow, err := buildflags.ParseEntitlements(in.Allow) if err != nil {