From 9f1daffb32a45af1125410de9a6ac5022f8a4670 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Mon, 12 Jan 2026 21:41:44 -0800 Subject: [PATCH] policy: implement policy support for bake Signed-off-by: Tonis Tiigi --- bake/bake.go | 69 ++++++---- build/build.go | 14 +-- build/policy_test.go | 21 ++-- commands/build.go | 68 +--------- docs/bake-reference.md | 16 +++ tests/integration_test.go | 1 + tests/policy_bake.go | 182 +++++++++++++++++++++++++++ tests/policy_build.go | 6 +- tests/policy_eval.go | 2 +- util/buildflags/policy.go | 93 ++++++++++++++ util/buildflags/policy_cty.go | 149 ++++++++++++++++++++++ util/buildflags/policy_test.go | 94 ++++++++++++++ util/gitutil/gittestutil/testutil.go | 7 ++ 13 files changed, 605 insertions(+), 117 deletions(-) create mode 100644 tests/policy_bake.go create mode 100644 util/buildflags/policy.go create mode 100644 util/buildflags/policy_cty.go create mode 100644 util/buildflags/policy_test.go diff --git a/bake/bake.go b/bake/bake.go index 7a89e0d1e..057f021df 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -588,7 +588,7 @@ func (c Config) newOverrides(v []string) (map[string]map[string]Override, error) // IMPORTANT: if you add more fields here, do not forget to update // docs/reference/buildx_bake.md (--set) and https://docs.docker.com/build/bake/overrides/ switch keys[1] { - case "output", "cache-to", "cache-from", "tags", "platform", "secrets", "ssh", "attest", "entitlements", "network", "annotations": + case "output", "cache-to", "cache-from", "tags", "platform", "secrets", "ssh", "attest", "entitlements", "network", "annotations", "policy": if len(parts) == 2 { override.Append = appendTo override.ArrValue = append(override.ArrValue, parts[1]) @@ -732,31 +732,32 @@ type Target struct { // Inherits is the only field that cannot be overridden with --set Inherits []string `json:"inherits,omitempty" hcl:"inherits,optional" cty:"inherits"` - Annotations []string `json:"annotations,omitempty" hcl:"annotations,optional" cty:"annotations"` - Attest buildflags.Attests `json:"attest,omitempty" hcl:"attest,optional" cty:"attest"` - Context *string `json:"context,omitempty" hcl:"context,optional" cty:"context"` - Contexts map[string]string `json:"contexts,omitempty" hcl:"contexts,optional" cty:"contexts"` - Dockerfile *string `json:"dockerfile,omitempty" hcl:"dockerfile,optional" cty:"dockerfile"` - DockerfileInline *string `json:"dockerfile-inline,omitempty" hcl:"dockerfile-inline,optional" cty:"dockerfile-inline"` - Args map[string]*string `json:"args,omitempty" hcl:"args,optional" cty:"args"` - Labels map[string]*string `json:"labels,omitempty" hcl:"labels,optional" cty:"labels"` - Tags []string `json:"tags,omitempty" hcl:"tags,optional" cty:"tags"` - CacheFrom buildflags.CacheOptions `json:"cache-from,omitempty" hcl:"cache-from,optional" cty:"cache-from"` - CacheTo buildflags.CacheOptions `json:"cache-to,omitempty" hcl:"cache-to,optional" cty:"cache-to"` - Target *string `json:"target,omitempty" hcl:"target,optional" cty:"target"` - Secrets buildflags.Secrets `json:"secret,omitempty" hcl:"secret,optional" cty:"secret"` - SSH buildflags.SSHKeys `json:"ssh,omitempty" hcl:"ssh,optional" cty:"ssh"` - Platforms []string `json:"platforms,omitempty" hcl:"platforms,optional" cty:"platforms"` - Outputs buildflags.Exports `json:"output,omitempty" hcl:"output,optional" cty:"output"` - Pull *bool `json:"pull,omitempty" hcl:"pull,optional" cty:"pull"` - NoCache *bool `json:"no-cache,omitempty" hcl:"no-cache,optional" cty:"no-cache"` - NetworkMode *string `json:"network,omitempty" hcl:"network,optional" cty:"network"` - NoCacheFilter []string `json:"no-cache-filter,omitempty" hcl:"no-cache-filter,optional" cty:"no-cache-filter"` - ShmSize *string `json:"shm-size,omitempty" hcl:"shm-size,optional" cty:"shm-size"` - Ulimits []string `json:"ulimits,omitempty" hcl:"ulimits,optional" cty:"ulimits"` - Call *string `json:"call,omitempty" hcl:"call,optional" cty:"call"` - Entitlements []string `json:"entitlements,omitempty" hcl:"entitlements,optional" cty:"entitlements"` - ExtraHosts map[string]*string `json:"extra-hosts,omitempty" hcl:"extra-hosts,optional" cty:"extra-hosts"` + Annotations []string `json:"annotations,omitempty" hcl:"annotations,optional" cty:"annotations"` + Attest buildflags.Attests `json:"attest,omitempty" hcl:"attest,optional" cty:"attest"` + Context *string `json:"context,omitempty" hcl:"context,optional" cty:"context"` + Contexts map[string]string `json:"contexts,omitempty" hcl:"contexts,optional" cty:"contexts"` + Dockerfile *string `json:"dockerfile,omitempty" hcl:"dockerfile,optional" cty:"dockerfile"` + DockerfileInline *string `json:"dockerfile-inline,omitempty" hcl:"dockerfile-inline,optional" cty:"dockerfile-inline"` + Args map[string]*string `json:"args,omitempty" hcl:"args,optional" cty:"args"` + Labels map[string]*string `json:"labels,omitempty" hcl:"labels,optional" cty:"labels"` + Tags []string `json:"tags,omitempty" hcl:"tags,optional" cty:"tags"` + CacheFrom buildflags.CacheOptions `json:"cache-from,omitempty" hcl:"cache-from,optional" cty:"cache-from"` + CacheTo buildflags.CacheOptions `json:"cache-to,omitempty" hcl:"cache-to,optional" cty:"cache-to"` + Target *string `json:"target,omitempty" hcl:"target,optional" cty:"target"` + Secrets buildflags.Secrets `json:"secret,omitempty" hcl:"secret,optional" cty:"secret"` + SSH buildflags.SSHKeys `json:"ssh,omitempty" hcl:"ssh,optional" cty:"ssh"` + Platforms []string `json:"platforms,omitempty" hcl:"platforms,optional" cty:"platforms"` + Outputs buildflags.Exports `json:"output,omitempty" hcl:"output,optional" cty:"output"` + Pull *bool `json:"pull,omitempty" hcl:"pull,optional" cty:"pull"` + NoCache *bool `json:"no-cache,omitempty" hcl:"no-cache,optional" cty:"no-cache"` + NetworkMode *string `json:"network,omitempty" hcl:"network,optional" cty:"network"` + NoCacheFilter []string `json:"no-cache-filter,omitempty" hcl:"no-cache-filter,optional" cty:"no-cache-filter"` + ShmSize *string `json:"shm-size,omitempty" hcl:"shm-size,optional" cty:"shm-size"` + Ulimits []string `json:"ulimits,omitempty" hcl:"ulimits,optional" cty:"ulimits"` + Call *string `json:"call,omitempty" hcl:"call,optional" cty:"call"` + Entitlements []string `json:"entitlements,omitempty" hcl:"entitlements,optional" cty:"entitlements"` + ExtraHosts map[string]*string `json:"extra-hosts,omitempty" hcl:"extra-hosts,optional" cty:"extra-hosts"` + Policy buildflags.PolicyConfigs `json:"policy,omitempty" hcl:"policy,optional" cty:"policy"` // IMPORTANT: if you add more fields here, do not forget to update newOverrides/AddOverrides and docs/bake-reference.md. // linked is a private field to mark a target used as a linked one @@ -891,6 +892,9 @@ func (t *Target) Merge(t2 *Target) { if t2.Attest != nil { // merge t.Attest = t.Attest.Merge(t2.Attest) } + if t2.Policy != nil { // merge + t.Policy = append(t.Policy, t2.Policy...) + } if t2.Secrets != nil { // merge t.Secrets = t.Secrets.Merge(t2.Secrets) } @@ -986,6 +990,17 @@ func (t *Target) AddOverrides(overrides map[string]Override, ent *EntitlementCon } else { t.Tags = o.ArrValue } + case "policy": + if !o.Append { + t.Policy = nil + } + for _, v := range o.ArrValue { + cfg, err := buildflags.ParsePolicyConfig(v) + if err != nil { + return err + } + t.Policy = append(t.Policy, cfg) + } case "cache-from": cacheFrom, err := buildflags.ParseCacheEntry(o.ArrValue) if err != nil { @@ -1548,6 +1563,8 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) { bo.Attests = t.Attest.ToMap() + bo.Policy = []buildflags.PolicyConfig(t.Policy) + bo.SourcePolicy, err = build.ReadSourcePolicy() if err != nil { return nil, err diff --git a/build/build.go b/build/build.go index 5211be5b8..738a82190 100644 --- a/build/build.go +++ b/build/build.go @@ -97,15 +97,7 @@ 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 + Policy []buildflags.PolicyConfig } type CallFunc struct { @@ -135,7 +127,7 @@ type policyOpt struct { LogLevel *logrus.Level } -func withPolicyConfig(defaultPolicy policyOpt, configs []PolicyConfig) ([]policyOpt, error) { +func withPolicyConfig(defaultPolicy policyOpt, configs []buildflags.PolicyConfig) ([]policyOpt, error) { if len(configs) == 0 { if len(defaultPolicy.Files) == 0 { return nil, nil @@ -161,7 +153,7 @@ func withPolicyConfig(defaultPolicy policyOpt, configs []PolicyConfig) ([]policy out = append(out, defaultPolicy) } - var last PolicyConfig + var last buildflags.PolicyConfig for _, cfg := range configs { if cfg.Reset { diff --git a/build/policy_test.go b/build/policy_test.go index 9a6cd0103..33760725d 100644 --- a/build/policy_test.go +++ b/build/policy_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/docker/buildx/policy" + "github.com/docker/buildx/util/buildflags" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) @@ -39,33 +40,33 @@ func TestWithPolicyConfigDefaults(t *testing.T) { // TestWithPolicyConfigDisabled validates disabled policy behavior across invalid and valid combinations. func TestWithPolicyConfigDisabled(t *testing.T) { - _, err := withPolicyConfig(policyOpt{}, []PolicyConfig{ + _, err := withPolicyConfig(policyOpt{}, []buildflags.PolicyConfig{ {Disabled: true, Files: []policy.File{{Filename: "x.rego"}}}, }) require.Error(t, err) - _, err = withPolicyConfig(policyOpt{}, []PolicyConfig{ + _, err = withPolicyConfig(policyOpt{}, []buildflags.PolicyConfig{ {Disabled: true, Reset: true}, }) require.Error(t, err) - _, err = withPolicyConfig(policyOpt{}, []PolicyConfig{ + _, err = withPolicyConfig(policyOpt{}, []buildflags.PolicyConfig{ {Disabled: true, Strict: boolPtr(true)}, }) require.Error(t, err) - _, err = withPolicyConfig(policyOpt{}, []PolicyConfig{ + _, err = withPolicyConfig(policyOpt{}, []buildflags.PolicyConfig{ {Disabled: true, LogLevel: levelPtr(logrus.WarnLevel)}, }) require.Error(t, err) - _, err = withPolicyConfig(policyOpt{}, []PolicyConfig{ + _, err = withPolicyConfig(policyOpt{}, []buildflags.PolicyConfig{ {Disabled: true}, {}, }) require.Error(t, err) - out, err := withPolicyConfig(policyOpt{}, []PolicyConfig{ + out, err := withPolicyConfig(policyOpt{}, []buildflags.PolicyConfig{ {Disabled: true}, }) require.NoError(t, err) @@ -81,7 +82,7 @@ func TestWithPolicyConfigResetAndFiles(t *testing.T) { }, } - out, err := withPolicyConfig(defaultPolicy, []PolicyConfig{ + out, err := withPolicyConfig(defaultPolicy, []buildflags.PolicyConfig{ {Reset: true}, {Files: []policy.File{{Filename: "a.rego"}}}, }) @@ -97,7 +98,7 @@ func TestWithPolicyConfigStrictAndLogLevel(t *testing.T) { Files: []policy.File{{Filename: "default.rego"}}, } - out, err := withPolicyConfig(defaultPolicy, []PolicyConfig{ + out, err := withPolicyConfig(defaultPolicy, []buildflags.PolicyConfig{ {Strict: boolPtr(true), LogLevel: levelPtr(logrus.WarnLevel)}, }) require.NoError(t, err) @@ -109,7 +110,7 @@ func TestWithPolicyConfigStrictAndLogLevel(t *testing.T) { // TestWithPolicyConfigStrictIgnoredWithoutPolicy ensures strict without any policy produces no entries. func TestWithPolicyConfigStrictIgnoredWithoutPolicy(t *testing.T) { - out, err := withPolicyConfig(policyOpt{}, []PolicyConfig{ + out, err := withPolicyConfig(policyOpt{}, []buildflags.PolicyConfig{ {Strict: boolPtr(true)}, }) require.NoError(t, err) @@ -125,7 +126,7 @@ func TestWithPolicyConfigMultipleFilesAndOverrides(t *testing.T) { }, } - out, err := withPolicyConfig(defaultPolicy, []PolicyConfig{ + out, err := withPolicyConfig(defaultPolicy, []buildflags.PolicyConfig{ {Files: []policy.File{{Filename: "a.rego"}}}, {Strict: boolPtr(true), LogLevel: levelPtr(logrus.WarnLevel)}, {Files: []policy.File{{Filename: "b.rego"}}, Strict: boolPtr(true)}, diff --git a/commands/build.go b/commands/build.go index 8cee3aed4..66c6bcec5 100644 --- a/commands/build.go +++ b/commands/build.go @@ -20,7 +20,6 @@ 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" @@ -57,7 +56,6 @@ 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" @@ -153,7 +151,7 @@ func (o *buildOptions) toOptions() (*BuildOptions, error) { return nil, err } - opts.Policy, err = parsePolicyConfigs(o.policy) + opts.Policy, err = buildflags.ParsePolicyConfigs(o.policy) if err != nil { return nil, err } @@ -236,68 +234,6 @@ 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) - } - dt, err := os.ReadFile(value) - if err != nil { - return nil, errors.Wrapf(err, "failed to read policy file %s", value) - } - cfg.Files = append(cfg.Files, policy.File{Filename: value, Data: dt}) - 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") @@ -1048,7 +984,7 @@ type BuildOptions struct { GroupRef string Annotations []string ProvenanceResponseMode string - Policy []build.PolicyConfig + Policy []buildflags.PolicyConfig } // RunBuild runs the specified build and returns the result. diff --git a/docs/bake-reference.md b/docs/bake-reference.md index a5527b06a..2043f6f1b 100644 --- a/docs/bake-reference.md +++ b/docs/bake-reference.md @@ -236,6 +236,7 @@ The following table shows the complete list of attributes that you can assign to | [`no-cache-filter`](#targetno-cache-filter) | List | Disable build cache for specific stages | | [`no-cache`](#targetno-cache) | Boolean | Disable build cache completely | | [`output`](#targetoutput) | List | Output destinations | +| [`policy`](#targetpolicy) | List | Policies to validate build sources and metadata | | [`platforms`](#targetplatforms) | List | Target platforms | | [`pull`](#targetpull) | Boolean | Always pull images | | [`secret`](#targetsecret) | List | Secrets to expose to the build | @@ -899,6 +900,21 @@ target "default" { } ``` +### `target.policy` + +Policies to validate build sources and metadata. Each entry uses the same keys +as the `--policy` flag for `docker buildx build` (`filename`, `reset`, +`disabled`, `strict`, `log-level`). Bake also automatically loads +`Dockerfile.rego` alongside the target Dockerfile when present. + +```hcl +target "default" { + policy = [ + { filename = "extra.rego" }, + ] +} +``` + ### `target.platforms` Set target platforms for the build target. diff --git a/tests/integration_test.go b/tests/integration_test.go index 72b2dd29e..550ed2d3d 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -25,6 +25,7 @@ func TestIntegration(t *testing.T) { tests = append(tests, buildTests...) tests = append(tests, policyBuildTests...) tests = append(tests, policyEvalTests...) + tests = append(tests, policyBakeTests...) tests = append(tests, bakeTests...) tests = append(tests, historyTests...) tests = append(tests, inspectTests...) diff --git a/tests/policy_bake.go b/tests/policy_bake.go new file mode 100644 index 000000000..a417de9ba --- /dev/null +++ b/tests/policy_bake.go @@ -0,0 +1,182 @@ +package tests + +import ( + "testing" + + "github.com/containerd/continuity/fs/fstest" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/stretchr/testify/require" +) + +var policyBakeTests = []func(t *testing.T, sb integration.Sandbox){ + testBakePolicyConfigFlags, +} + +func testBakePolicyConfigFlags(t *testing.T, sb integration.Sandbox) { + skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+") + + dockerfile := []byte("FROM scratch\n") + defaultPolicy := []byte(` +package docker + +default allow = false + +allow if input.env.args["DEFAULT_OK"] == "1" + +decision := {"allow": allow} +`) + extraPolicy := []byte(` +package docker + +default allow = false + +allow if input.env.labels["com.example.extra"] == "1" + +decision := {"allow": allow} +`) + bakeFile := []byte(` +target "pass-both" { + context = "." + dockerfile = "Dockerfile" + args = { + DEFAULT_OK = "1" + } + labels = { + "com.example.extra" = "1" + } + policy = [ + { filename = "extra.rego" }, + ] + output = ["type=cacheonly"] +} + +target "fail-default" { + context = "." + dockerfile = "Dockerfile" + labels = { + "com.example.extra" = "1" + } + policy = [ + { filename = "extra.rego" }, + ] + output = ["type=cacheonly"] +} + +target "fail-extra" { + context = "." + dockerfile = "Dockerfile" + args = { + DEFAULT_OK = "1" + } + policy = [ + { filename = "extra.rego" }, + ] + output = ["type=cacheonly"] +} + +target "reset-pass" { + context = "." + dockerfile = "Dockerfile" + labels = { + "com.example.extra" = "1" + } + policy = [ + { filename = "extra.rego", reset = true }, + ] + output = ["type=cacheonly"] +} + +target "reset-fail" { + context = "." + dockerfile = "Dockerfile" + policy = [ + { filename = "extra.rego", reset = true }, + ] + output = ["type=cacheonly"] +} + +target "disabled" { + context = "." + dockerfile = "Dockerfile" + policy = [ + { disabled = true }, + ] + output = ["type=cacheonly"] +} + +target "disabled-combined" { + context = "." + dockerfile = "Dockerfile" + policy = [ + { filename = "extra.rego" }, + { disabled = true }, + ] + output = ["type=cacheonly"] +} +`) + + dir := tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("Dockerfile.rego", defaultPolicy, 0600), + fstest.CreateFile("extra.rego", extraPolicy, 0600), + fstest.CreateFile("docker-bake.hcl", bakeFile, 0600), + ) + + cases := []struct { + name string + target string + wantErrContains string + }{ + { + name: "additional-policy-requires-default", + target: "pass-both", + }, + { + name: "additional-policy-missing-default", + target: "fail-default", + wantErrContains: "not allowed by policy", + }, + { + name: "additional-policy-missing-extra", + target: "fail-extra", + wantErrContains: "not allowed by policy", + }, + { + name: "reset-ignores-default", + target: "reset-pass", + }, + { + name: "reset-requires-extra", + target: "reset-fail", + wantErrContains: "not allowed by policy", + }, + { + name: "disabled-skips-default", + target: "disabled", + }, + { + name: "disabled-cannot-combine", + target: "disabled-combined", + wantErrContains: "disabled policy cannot be combined with other policy flags", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := buildxCmd(sb, withDir(dir), withArgs( + "bake", + "--progress=plain", + "--file", "docker-bake.hcl", + tc.target, + )) + out, err := cmd.CombinedOutput() + if tc.wantErrContains == "" { + require.NoError(t, err, string(out)) + return + } + require.Error(t, err, string(out)) + require.Contains(t, string(out), tc.wantErrContains) + }) + } +} diff --git a/tests/policy_build.go b/tests/policy_build.go index eba886e0d..fe8180545 100644 --- a/tests/policy_build.go +++ b/tests/policy_build.go @@ -703,9 +703,10 @@ decision := {"allow": allow} } skipNoCompatBuildKit(t, sb, ">= 0.26.3-0", "http checksum policy input") } + dockerfile := fmt.Appendf(nil, "FROM busybox:latest\nADD %s /tmp/file\n", tc.addURL) dir := tmpdir( t, - fstest.CreateFile("Dockerfile", []byte(fmt.Sprintf("FROM busybox:latest\nADD %s /tmp/file\n", tc.addURL)), 0600), + fstest.CreateFile("Dockerfile", dockerfile, 0600), fstest.CreateFile("policy.rego", []byte(tc.policy), 0600), ) policyPath := filepath.Join(dir, "policy.rego") @@ -747,8 +748,7 @@ func testBuildPolicyGit(t *testing.T, sb integration.Sandbox) { gittestutil.GitAdd(git, t, "Dockerfile", "a") gittestutil.GitCommit(git, t, "initial commit") - _, err = git.Run("tag", "-a", "v0.1", "-m", "v0.1release") - require.NoError(t, err) + gittestutil.GitTagAnnotated(git, t, "v0.1", "v0.1release") require.NoError(t, os.WriteFile(filepath.Join(gitDir, "b"), []byte("b"), 0600)) gittestutil.GitAdd(git, t, "b") diff --git a/tests/policy_eval.go b/tests/policy_eval.go index a80888530..1fbff5f72 100644 --- a/tests/policy_eval.go +++ b/tests/policy_eval.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + urlpkg "net/url" "testing" "github.com/containerd/continuity/fs/fstest" @@ -14,7 +15,6 @@ import ( "github.com/moby/buildkit/util/testutil/integration" digest "github.com/opencontainers/go-digest" "github.com/stretchr/testify/require" - urlpkg "net/url" ) var policyEvalTests = []func(t *testing.T, sb integration.Sandbox){ diff --git a/util/buildflags/policy.go b/util/buildflags/policy.go new file mode 100644 index 000000000..00d327c01 --- /dev/null +++ b/util/buildflags/policy.go @@ -0,0 +1,93 @@ +package buildflags + +import ( + "os" + "strconv" + "strings" + + "github.com/docker/buildx/policy" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/tonistiigi/go-csvvalue" +) + +type PolicyConfig struct { + Files []policy.File + Reset bool + Disabled bool + Strict *bool + LogLevel *logrus.Level +} + +func ParsePolicyConfigs(in []string) ([]PolicyConfig, error) { + if len(in) == 0 { + return nil, nil + } + + out := make([]PolicyConfig, 0, len(in)) + for _, s := range in { + cfg, err := ParsePolicyConfig(s) + if err != nil { + return nil, err + } + out = append(out, cfg) + } + return out, nil +} + +func ParsePolicyConfig(value string) (PolicyConfig, error) { + fields, err := csvvalue.Fields(value, nil) + if err != nil { + return PolicyConfig{}, err + } + return parsePolicyFields(fields) +} + +func parsePolicyFields(fields []string) (PolicyConfig, error) { + cfg := PolicyConfig{} + for _, field := range fields { + key, value, ok := strings.Cut(field, "=") + if !ok { + return PolicyConfig{}, errors.Errorf("invalid value %s", field) + } + key = strings.TrimSpace(strings.ToLower(key)) + switch key { + case "filename": + if value == "" { + return PolicyConfig{}, errors.Errorf("invalid value %s", field) + } + dt, err := os.ReadFile(value) + if err != nil { + return PolicyConfig{}, errors.Wrapf(err, "failed to read policy file %s", value) + } + cfg.Files = append(cfg.Files, policy.File{Filename: value, Data: dt}) + case "reset": + b, err := strconv.ParseBool(value) + if err != nil { + return PolicyConfig{}, errors.Wrapf(err, "invalid value %s", field) + } + cfg.Reset = b + case "disabled": + b, err := strconv.ParseBool(value) + if err != nil { + return PolicyConfig{}, errors.Wrapf(err, "invalid value %s", field) + } + cfg.Disabled = b + case "strict": + b, err := strconv.ParseBool(value) + if err != nil { + return PolicyConfig{}, errors.Wrapf(err, "invalid value %s", field) + } + cfg.Strict = &b + case "log-level": + lvl, err := logrus.ParseLevel(value) + if err != nil { + return PolicyConfig{}, errors.Wrapf(err, "invalid value %s", field) + } + cfg.LogLevel = &lvl + default: + return PolicyConfig{}, errors.Errorf("invalid value %s", field) + } + } + return cfg, nil +} diff --git a/util/buildflags/policy_cty.go b/util/buildflags/policy_cty.go new file mode 100644 index 000000000..46bf34c87 --- /dev/null +++ b/util/buildflags/policy_cty.go @@ -0,0 +1,149 @@ +package buildflags + +import ( + "fmt" + "math/big" + "strconv" + "sync" + + "github.com/pkg/errors" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" +) + +type PolicyConfigs []PolicyConfig + +var policyConfigType = sync.OnceValue(func() cty.Type { + return cty.Map(cty.String) +}) + +func (p *PolicyConfigs) FromCtyValue(in cty.Value, path cty.Path) error { + got := in.Type() + if got.IsTupleType() || got.IsListType() { + return p.fromCtyValue(in, path) + } + + want := cty.List(policyConfigType()) + return path.NewErrorf("%s", convert.MismatchMessage(got, want)) +} + +func (p *PolicyConfigs) fromCtyValue(in cty.Value, path cty.Path) (retErr error) { + *p = make([]PolicyConfig, 0, in.LengthInt()) + + yield := func(value cty.Value) bool { + if value.Type() == cty.String { + var cfg PolicyConfig + cfg, retErr = ParsePolicyConfig(value.AsString()) + if retErr != nil { + return false + } + *p = append(*p, cfg) + return true + } + + if value.Type().IsObjectType() || value.Type().IsMapType() { + var cfg PolicyConfig + cfg, retErr = policyConfigFromMap(value) + if retErr != nil { + return false + } + *p = append(*p, cfg) + return true + } + + retErr = path.NewErrorf("%s", convert.MismatchMessage(value.Type(), policyConfigType())) + return false + } + eachElement(in)(yield) + return retErr +} + +func (p PolicyConfigs) ToCtyValue() cty.Value { + if len(p) == 0 { + return cty.ListValEmpty(policyConfigType()) + } + + vals := make([]cty.Value, len(p)) + for i, entry := range p { + vals[i] = entry.ToCtyValue() + } + return cty.ListVal(vals) +} + +func (p *PolicyConfig) FromCtyValue(in cty.Value, path cty.Path) error { + if in.Type() == cty.String { + cfg, err := ParsePolicyConfig(in.AsString()) + if err != nil { + return path.NewError(err) + } + *p = cfg + return nil + } + + if in.Type().IsObjectType() || in.Type().IsMapType() { + cfg, err := policyConfigFromMap(in) + if err != nil { + return path.NewError(err) + } + *p = cfg + return nil + } + + return path.NewErrorf("%s", convert.MismatchMessage(in.Type(), policyConfigType())) +} + +func (p PolicyConfig) ToCtyValue() cty.Value { + vals := map[string]cty.Value{} + if len(p.Files) > 0 { + vals["filename"] = cty.StringVal(p.Files[0].Filename) + } + if p.Reset { + vals["reset"] = cty.StringVal(strconv.FormatBool(p.Reset)) + } + if p.Disabled { + vals["disabled"] = cty.StringVal(strconv.FormatBool(p.Disabled)) + } + if p.Strict != nil { + vals["strict"] = cty.StringVal(strconv.FormatBool(*p.Strict)) + } + if p.LogLevel != nil { + vals["log-level"] = cty.StringVal(p.LogLevel.String()) + } + if len(vals) == 0 { + return cty.MapValEmpty(cty.String) + } + return cty.MapVal(vals) +} + +func policyConfigFromMap(in cty.Value) (PolicyConfig, error) { + fields := make([]string, 0) + for k, v := range in.AsValueMap() { + if v.IsNull() || !v.IsKnown() { + continue + } + if v.Type() == cty.String && v.AsString() == "" { + continue + } + field, err := policyField(k, v) + if err != nil { + return PolicyConfig{}, err + } + fields = append(fields, field) + } + return parsePolicyFields(fields) +} + +func policyField(key string, value cty.Value) (string, error) { + switch value.Type() { + case cty.String: + return fmt.Sprintf("%s=%s", key, value.AsString()), nil + case cty.Bool: + return fmt.Sprintf("%s=%t", key, value.True()), nil + case cty.Number: + var f big.Float + f.Set(value.AsBigFloat()) + return fmt.Sprintf("%s=%s", key, f.Text('f', -1)), nil + default: + return "", errors.Errorf("%s", convert.MismatchMessage(value.Type(), cty.String)) + } +} diff --git a/util/buildflags/policy_test.go b/util/buildflags/policy_test.go new file mode 100644 index 000000000..ff678a89a --- /dev/null +++ b/util/buildflags/policy_test.go @@ -0,0 +1,94 @@ +package buildflags + +import ( + "os" + "path/filepath" + "testing" + + "github.com/docker/buildx/policy" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" + "github.com/zclconf/go-cty/cty" +) + +func TestPolicyConfigs_FromCtyValue(t *testing.T) { + policyDir := t.TempDir() + policyPath := filepath.Join(policyDir, "policy.rego") + policyData := []byte("package docker\n") + require.NoError(t, os.WriteFile(policyPath, policyData, 0o600)) + + in := cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "filename": cty.StringVal(policyPath), + "reset": cty.BoolVal(true), + "strict": cty.BoolVal(true), + "log-level": cty.StringVal("warn"), + }), + cty.StringVal("filename=" + policyPath + ",disabled=true"), + }) + + var actual PolicyConfigs + err := actual.FromCtyValue(in, nil) + require.NoError(t, err) + require.Len(t, actual, 2) + + require.Equal(t, policyPath, actual[0].Files[0].Filename) + require.Equal(t, policyData, actual[0].Files[0].Data) + require.True(t, actual[0].Reset) + require.NotNil(t, actual[0].Strict) + require.True(t, *actual[0].Strict) + require.NotNil(t, actual[0].LogLevel) + require.Equal(t, logrus.WarnLevel, *actual[0].LogLevel) + + require.Equal(t, policyPath, actual[1].Files[0].Filename) + require.Equal(t, policyData, actual[1].Files[0].Data) + require.True(t, actual[1].Disabled) +} + +func TestPolicyConfigs_ToCtyValue(t *testing.T) { + lvl := logrus.InfoLevel + strict := true + in := PolicyConfigs{ + { + Files: []policy.File{{Filename: "a.rego"}}, + Reset: true, + }, + { + Files: []policy.File{{Filename: "b.rego"}}, + Disabled: true, + Strict: &strict, + LogLevel: &lvl, + }, + } + + actual := in.ToCtyValue() + expected := cty.ListVal([]cty.Value{ + cty.MapVal(map[string]cty.Value{ + "filename": cty.StringVal("a.rego"), + "reset": cty.StringVal("true"), + }), + cty.MapVal(map[string]cty.Value{ + "filename": cty.StringVal("b.rego"), + "disabled": cty.StringVal("true"), + "strict": cty.StringVal("true"), + "log-level": cty.StringVal("info"), + }), + }) + + result := actual.Equals(expected) + require.True(t, result.True()) +} + +func TestPolicyConfig_FromCtyValue(t *testing.T) { + policyDir := t.TempDir() + policyPath := filepath.Join(policyDir, "policy.rego") + policyData := []byte("package docker\n") + require.NoError(t, os.WriteFile(policyPath, policyData, 0o600)) + + var actual PolicyConfig + err := actual.FromCtyValue(cty.StringVal("filename="+policyPath+",disabled=true"), nil) + require.NoError(t, err) + require.Equal(t, policyPath, actual.Files[0].Filename) + require.Equal(t, policyData, actual.Files[0].Data) + require.True(t, actual.Disabled) +} diff --git a/util/gitutil/gittestutil/testutil.go b/util/gitutil/gittestutil/testutil.go index e50ec74ef..a88c3aa61 100644 --- a/util/gitutil/gittestutil/testutil.go +++ b/util/gitutil/gittestutil/testutil.go @@ -33,6 +33,13 @@ func GitTag(c *gitutil.Git, tb testing.TB, tag string) { require.Empty(tb, out) } +func GitTagAnnotated(c *gitutil.Git, tb testing.TB, tag, message string) { + tb.Helper() + out, err := fakeGit(c, "tag", "-a", tag, "-m", message) + require.NoError(tb, err) + require.Empty(tb, out) +} + func GitCheckoutBranch(c *gitutil.Git, tb testing.TB, name string) { tb.Helper() out, err := fakeGit(c, "checkout", "-b", name)