diff --git a/bake/bake.go b/bake/bake.go index 7a89e0d1ef1b..057f021df0d8 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 5211be5b86cf..738a82190964 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 9a6cd010362f..33760725dd4e 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 8cee3aed4846..66c6bcec5158 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/commands/policy/eval.go b/commands/policy/eval.go index 201fefc546bd..bdad0d08e5fc 100644 --- a/commands/policy/eval.go +++ b/commands/policy/eval.go @@ -10,6 +10,7 @@ import ( "slices" "strings" + "github.com/containerd/errdefs" "github.com/distribution/reference" "github.com/docker/buildx/builder" "github.com/docker/buildx/policy" @@ -408,7 +409,7 @@ func parseSource(input string) (*pb.SourceOp, error) { }, }, nil } - if err != nil { + if err != nil && !errors.Is(err, errdefs.ErrInvalidArgument) { return nil, err } return &pb.SourceOp{Identifier: input}, nil diff --git a/docs/bake-reference.md b/docs/bake-reference.md index a5527b06abdc..2043f6f1b923 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/policy/validate.go b/policy/validate.go index 48b2519b9644..0c83da3cd45c 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -236,7 +236,7 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy if err := AddUnknownsWithLogger(p.opt.Log, next, unk); err != nil { return nil, nil, err } - if next.Image != nil || next.Git != nil { + if next.Image != nil || next.Git != nil || hasHTTPUnknowns(unk) { p.log(logrus.InfoLevel, "policy decision for source %s: resolve missing fields %+v", src.Source.Identifier, summarizeUnknownsForLog(unk)) return nil, next, nil } @@ -347,13 +347,14 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv Path: u.Path, Query: u.Query(), } - if _, ok := src.Source.Attrs[pb.AttrHTTPAuthHeaderSecret]; ok { - inp.HTTP.HasAuth = true + if src.HTTP != nil { + inp.HTTP.Checksum = src.HTTP.Checksum } - if src.Image == nil { + if inp.HTTP.Checksum == "" { unknowns = append(unknowns, "input.http.checksum") - } else { - inp.HTTP.Checksum = src.Image.Digest + } + if _, ok := src.Source.Attrs[pb.AttrHTTPAuthHeaderSecret]; ok { + inp.HTTP.HasAuth = true } case "git": if !gitutil.IsGitTransport(refstr) { @@ -625,8 +626,13 @@ func AddUnknownsWithLogger(logf func(logrus.Level, string), req *gwpb.ResolveSou } req.Image.AttestationChain = true - case "git.ref", "git.checksum", "git.commitChecksum", "git.isAnnotatedTag", "git.isSHA256", "git.tagName", "git.branch": + case "http.checksum": + // HTTP checksums are resolved by BuildKit for the HTTP source itself. + case "git.ref", "git.checksum", "git.commitChecksum", "git.isAnnotatedTag", "git.isSHA256", "git.tagName", "git.branch": + if req.Git == nil { + req.Git = &gwpb.ResolveSourceGitRequest{} + } case "git.commit", "git.tag": if req.Git == nil { req.Git = &gwpb.ResolveSourceGitRequest{} @@ -678,6 +684,15 @@ func summarizeUnknownsForLog(unk []string) []string { return out } +func hasHTTPUnknowns(unk []string) bool { + for _, u := range unk { + if strings.HasPrefix(u, "input.http.") { + return true + } + } + return false +} + func trimKey(s string) string { const ( dot = '.' diff --git a/tests/integration_test.go b/tests/integration_test.go index 72b2dd29e054..550ed2d3d543 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 000000000000..a417de9bae01 --- /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 f90bea10b811..fe8180545a46 100644 --- a/tests/policy_build.go +++ b/tests/policy_build.go @@ -3,16 +3,22 @@ package tests import ( "errors" "fmt" + "net/url" + "os" "path/filepath" "testing" "github.com/containerd/continuity/fs/fstest" "github.com/containerd/platforms" "github.com/distribution/reference" + "github.com/docker/buildx/util/gitutil" + "github.com/docker/buildx/util/gitutil/gittestutil" "github.com/moby/buildkit/identity" "github.com/moby/buildkit/util/contentutil" "github.com/moby/buildkit/util/testutil" + "github.com/moby/buildkit/util/testutil/httpserver" "github.com/moby/buildkit/util/testutil/integration" + digest "github.com/opencontainers/go-digest" "github.com/stretchr/testify/require" ) @@ -21,6 +27,9 @@ var policyBuildTests = []func(t *testing.T, sb integration.Sandbox){ testBuildPolicyDeny, testBuildPolicyImageName, testBuildPolicyEnv, + testBuildPolicyHTTP, + testBuildPolicyGit, + testBuildPolicyConfigFlags, } func testBuildPolicyAllow(t *testing.T, sb integration.Sandbox) { @@ -537,3 +546,636 @@ decision := {"allow": allow} }) } } + +func testBuildPolicyHTTP(t *testing.T, sb integration.Sandbox) { + skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+") + resp := &httpserver.Response{Content: []byte("policy-http")} + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/file": resp, + }) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + require.NoError(t, err) + + baseURL := server.URL + "/file" + queryURL := baseURL + "?policy=allow&case=http" + checksum := digest.FromBytes(resp.Content).String() + testCases := []struct { + name string + policy string + addURL string + wantErrContains string + requiresHTTPChecksum bool + }{ + { + name: "http-url-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if not input.http + +allow if input.http.url == "%s" + +decision := {"allow": allow} +`, queryURL), + addURL: queryURL, + }, + { + name: "http-schema-allow", + policy: ` +package docker + +default allow = false + +allow if not input.http + +allow if input.http.schema == "http" + +decision := {"allow": allow} +`, + addURL: baseURL, + }, + { + name: "http-host-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if not input.http + +allow if input.http.host == "%s" + +decision := {"allow": allow} +`, parsedURL.Host), + addURL: baseURL, + }, + { + name: "http-path-allow", + policy: ` +package docker + +default allow = false + +allow if not input.http + +allow if input.http.path == "/file" + +decision := {"allow": allow} +`, + addURL: baseURL, + }, + { + name: "http-query-allow", + policy: ` +package docker + +default allow = false + +allow if not input.http + +allow if input.http.query["policy"][_] == "allow" + +decision := {"allow": allow} +`, + addURL: queryURL, + }, + { + name: "http-checksum-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if not input.http + +allow if input.http.checksum == "%s" + +decision := {"allow": allow} +`, checksum), + addURL: baseURL, + requiresHTTPChecksum: true, + }, + { + name: "http-checksum-deny", + policy: ` +package docker + +default allow = false + +allow if not input.http + +allow if input.http.checksum == "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +decision := {"allow": allow} +`, + addURL: baseURL, + wantErrContains: "not allowed by policy", + requiresHTTPChecksum: true, + }, + { + name: "http-host-deny", + policy: ` +package docker + +default allow = false + +allow if not input.http + +allow if input.http.host == "example.invalid" + +decision := {"allow": allow} +`, + addURL: baseURL, + wantErrContains: "not allowed by policy", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.requiresHTTPChecksum { + sbDriver, _, _ := driverName(sb.Name()) + if sbDriver != "remote" { + t.Skip("http checksum policy input requires remote driver") + } + 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", dockerfile, 0600), + fstest.CreateFile("policy.rego", []byte(tc.policy), 0600), + ) + policyPath := filepath.Join(dir, "policy.rego") + policyArg := "filename=" + policyPath + if tc.name == "git-schema-allow" { + policyArg = "log-level=debug," + policyArg + } + + cmd := buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", policyArg, + "--output=type=cacheonly", + dir, + )) + out, err := cmd.CombinedOutput() + if tc.wantErrContains == "" { + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies "+policyPath) + } else { + require.Error(t, err, string(out)) + require.Contains(t, string(out), tc.wantErrContains) + } + }) + } +} + +func testBuildPolicyGit(t *testing.T, sb integration.Sandbox) { + skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+") + + gitDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "Dockerfile"), []byte("FROM busybox:latest\nRUN echo git\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "a"), []byte("a"), 0600)) + + git, err := gitutil.New(gitutil.WithWorkingDir(gitDir)) + require.NoError(t, err) + + gittestutil.GitInit(git, t) + gittestutil.GitAdd(git, t, "Dockerfile", "a") + gittestutil.GitCommit(git, t, "initial commit") + + 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") + gittestutil.GitCommit(git, t, "b") + _, err = git.Run("checkout", "-B", "v2") + require.NoError(t, err) + + commitHead, err := git.Run("rev-parse", "HEAD") + require.NoError(t, err) + commitTag, err := git.Run("rev-parse", "v0.1") + require.NoError(t, err) + commitTagCommit, err := git.Run("rev-parse", "v0.1^{commit}") + require.NoError(t, err) + baseURL := gittestutil.GitServeHTTP(git, t) + tagURL := baseURL + "#v0.1" + branchURL := baseURL + "#v2" + parsedURL, err := url.Parse(baseURL) + require.NoError(t, err) + + testCases := []struct { + name string + policy string + context string + wantErrContains string + requiresGitResolve bool + }{ + { + name: "git-schema-allow", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.schema != "" + +decision := {"allow": allow} +`, + context: baseURL, + }, + { + name: "git-host-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.host == "%s" + +decision := {"allow": allow} +`, parsedURL.Host), + context: baseURL, + }, + { + name: "git-remote-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if not input.git + +allow if endswith(input.git.remote, "%s") + +decision := {"allow": allow} +`, parsedURL.Path), + context: baseURL, + }, + { + name: "git-ref-tag-allow", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.ref == "refs/tags/v0.1" + +decision := {"allow": allow} +`, + context: tagURL, + requiresGitResolve: true, + }, + { + name: "git-branch-allow", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.branch == "v2" + +decision := {"allow": allow} +`, + context: branchURL, + requiresGitResolve: true, + }, + { + name: "git-tagname-allow", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.tagName == "v0.1" + +decision := {"allow": allow} +`, + context: tagURL, + requiresGitResolve: true, + }, + { + name: "git-checksum-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.checksum == "%s" + +decision := {"allow": allow} +`, commitTag), + context: tagURL, + requiresGitResolve: true, + }, + { + name: "git-commit-checksum-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.commitChecksum == "%s" + +decision := {"allow": allow} +`, commitTagCommit), + context: tagURL, + requiresGitResolve: true, + }, + { + name: "git-annotated-tag-allow", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.isAnnotatedTag == true + +decision := {"allow": allow} +`, + context: tagURL, + requiresGitResolve: true, + }, + { + name: "git-commit-message-allow", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.commit.message == "initial commit" + +decision := {"allow": allow} +`, + context: tagURL, + requiresGitResolve: true, + }, + { + name: "git-tag-object-allow", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.tag.tag == "v0.1" + +decision := {"allow": allow} +`, + context: tagURL, + requiresGitResolve: true, + }, + { + name: "git-checksum-deny", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.checksum == "deadbeef" + +decision := {"allow": allow} +`, + context: tagURL, + wantErrContains: "not allowed by policy", + requiresGitResolve: true, + }, + { + name: "git-commit-ref-allow", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.isCommitRef == true + +decision := {"allow": allow} +`, + context: baseURL + "#" + commitHead, + requiresGitResolve: true, + }, + { + name: "git-host-deny", + policy: ` +package docker + +default allow = false + +allow if not input.git + +allow if input.git.host == "example.invalid" + +decision := {"allow": allow} +`, + context: tagURL, + wantErrContains: "not allowed by policy", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.requiresGitResolve { + sbDriver, _, _ := driverName(sb.Name()) + if sbDriver != "remote" { + t.Skip("git policy metadata requires remote driver") + } + } + dir := tmpdir( + t, + fstest.CreateFile("policy.rego", []byte(tc.policy), 0600), + ) + policyPath := filepath.Join(dir, "policy.rego") + + cmd := buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", "filename="+policyPath, + "--output=type=cacheonly", + tc.context, + )) + out, err := cmd.CombinedOutput() + if tc.wantErrContains == "" { + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies "+policyPath) + } else { + require.Error(t, err, string(out)) + require.Contains(t, string(out), tc.wantErrContains) + } + }) + } +} + +func testBuildPolicyConfigFlags(t *testing.T, sb integration.Sandbox) { + skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+") + + dockerfile := []byte("FROM busybox:latest\nRUN echo policy-flags\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} +`) + denyPolicy := []byte(` +package docker + +default allow = false + +decision := {"allow": allow} +`) + + t.Run("additional-policy-requires-default", func(t *testing.T) { + dir := tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("Dockerfile.rego", defaultPolicy, 0600), + fstest.CreateFile("extra.rego", extraPolicy, 0600), + ) + extraPath := filepath.Join(dir, "extra.rego") + + cmd := buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", "filename="+extraPath, + "--build-arg", "DEFAULT_OK=1", + "--label", "com.example.extra=1", + "--output=type=cacheonly", + dir, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + cmd = buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", "filename="+extraPath, + "--label", "com.example.extra=1", + "--output=type=cacheonly", + dir, + )) + out, err = cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "not allowed by policy") + + cmd = buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", "filename="+extraPath, + "--build-arg", "DEFAULT_OK=1", + "--output=type=cacheonly", + dir, + )) + out, err = cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "not allowed by policy") + }) + + t.Run("reset-ignores-default", func(t *testing.T) { + dir := tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("Dockerfile.rego", defaultPolicy, 0600), + fstest.CreateFile("extra.rego", extraPolicy, 0600), + ) + extraPath := filepath.Join(dir, "extra.rego") + + cmd := buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", "reset=true,filename="+extraPath, + "--label", "com.example.extra=1", + "--output=type=cacheonly", + dir, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + cmd = buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", "reset=true,filename="+extraPath, + "--output=type=cacheonly", + dir, + )) + out, err = cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "not allowed by policy") + }) + + t.Run("disabled-skips-default", func(t *testing.T) { + dir := tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("Dockerfile.rego", denyPolicy, 0600), + ) + + cmd := buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", "disabled=true", + "--output=type=cacheonly", + dir, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + }) + + t.Run("disabled-cannot-combine-with-extra", func(t *testing.T) { + dir := tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("extra.rego", denyPolicy, 0600), + ) + extraPath := filepath.Join(dir, "extra.rego") + + cmd := buildxCmd(sb, withDir(dir), withArgs( + "build", + "--progress=plain", + "--policy", "filename="+extraPath, + "--policy", "disabled=true", + "--output=type=cacheonly", + dir, + )) + out, err := cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "disabled policy cannot be combined with other policy flags") + }) +} diff --git a/tests/policy_eval.go b/tests/policy_eval.go index 320a6decb40b..1fbff5f7286a 100644 --- a/tests/policy_eval.go +++ b/tests/policy_eval.go @@ -5,12 +5,15 @@ import ( "encoding/json" "errors" "fmt" + urlpkg "net/url" "testing" "github.com/containerd/continuity/fs/fstest" "github.com/docker/buildx/policy" "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/util/testutil/httpserver" "github.com/moby/buildkit/util/testutil/integration" + digest "github.com/opencontainers/go-digest" "github.com/stretchr/testify/require" ) @@ -20,6 +23,7 @@ var policyEvalTests = []func(t *testing.T, sb integration.Sandbox){ testPolicyEvalPrint, testPolicyEvalFields, testPolicyEvalLabel, + testPolicyEvalHTTP, } func testPolicyEvalAllow(t *testing.T, sb integration.Sandbox) { @@ -215,3 +219,151 @@ decision := {"allow": allow} out, err := cmd.CombinedOutput() require.NoError(t, err, string(out)) } + +func testPolicyEvalHTTP(t *testing.T, sb integration.Sandbox) { + resp := &httpserver.Response{Content: []byte("policy-eval-http")} + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/file": resp, + }) + defer server.Close() + + url := server.URL + "/file" + queryURL := url + "?policy=allow" + checksum := digest.FromBytes(resp.Content).String() + parsedURL, err := urlpkg.Parse(server.URL) + require.NoError(t, err) + + testCases := []struct { + name string + policy string + sourceURL string + wantErrContains string + needsChecksum bool + }{ + { + name: "http-url-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if input.http.url == "%s" + +decision := {"allow": allow} +`, queryURL), + sourceURL: queryURL, + }, + { + name: "http-host-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if input.http.host == "%s" + +decision := {"allow": allow} +`, parsedURL.Host), + sourceURL: url, + }, + { + name: "http-path-allow", + policy: ` +package docker + +default allow = false + +allow if input.http.path == "/file" + +decision := {"allow": allow} +`, + sourceURL: url, + }, + { + name: "http-query-allow", + policy: ` +package docker + +default allow = false + +allow if input.http.query["policy"][_] == "allow" + +decision := {"allow": allow} +`, + sourceURL: queryURL, + }, + { + name: "http-checksum-allow", + policy: fmt.Sprintf(` +package docker + +default allow = false + +allow if input.http.checksum == "%s" + +decision := {"allow": allow} +`, checksum), + sourceURL: url, + needsChecksum: true, + }, + { + name: "http-checksum-deny", + policy: ` +package docker + +default allow = false + +allow if input.http.checksum == "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +decision := {"allow": allow} +`, + sourceURL: url, + wantErrContains: "policy denied", + needsChecksum: true, + }, + { + name: "http-host-deny", + policy: ` +package docker + +default allow = false + +allow if input.http.host == "example.invalid" + +decision := {"allow": allow} +`, + sourceURL: url, + wantErrContains: "policy denied", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.needsChecksum { + sbDriver, _, _ := driverName(sb.Name()) + if sbDriver != "remote" { + t.Skip("http checksum policy eval requires remote driver") + } + skipNoCompatBuildKit(t, sb, ">= 0.26.3-0", "http checksum policy input") + } + dir := tmpdir( + t, + fstest.CreateFile("policy.rego", []byte(tc.policy), 0600), + ) + cmd := buildxCmd(sb, withDir(dir), withArgs( + "policy", + "eval", + "--filename", + "policy", + tc.sourceURL, + )) + out, err := cmd.CombinedOutput() + if tc.wantErrContains == "" { + require.NoError(t, err, string(out)) + } else { + require.Error(t, err, string(out)) + require.Contains(t, string(out), tc.wantErrContains) + } + }) + } +} diff --git a/util/buildflags/policy.go b/util/buildflags/policy.go new file mode 100644 index 000000000000..00d327c01347 --- /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 000000000000..46bf34c87144 --- /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 000000000000..ff678a89a334 --- /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 e50ec74ef9df..a88c3aa6163a 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) diff --git a/vendor/github.com/moby/buildkit/util/testutil/httpserver/server.go b/vendor/github.com/moby/buildkit/util/testutil/httpserver/server.go new file mode 100644 index 000000000000..589304c70e0a --- /dev/null +++ b/vendor/github.com/moby/buildkit/util/testutil/httpserver/server.go @@ -0,0 +1,113 @@ +package httpserver + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "slices" + "sync" + "time" +) + +type TestServer struct { + *httptest.Server + mu sync.Mutex + routes map[string]*Response + stats map[string]*Stat +} + +func NewTestServer(routes map[string]*Response) *TestServer { + ts := &TestServer{ + routes: routes, + stats: map[string]*Stat{}, + } + ts.Server = httptest.NewServer(ts) + return ts +} + +func (s *TestServer) SetRoute(name string, resp *Response) { + s.mu.Lock() + defer s.mu.Unlock() + s.routes[name] = resp +} + +func (s *TestServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + resp, ok := s.routes[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + s.mu.Unlock() + return + } + + if _, ok := s.stats[r.URL.Path]; !ok { + s.stats[r.URL.Path] = &Stat{} + } + + s.stats[r.URL.Path].AllRequests++ + s.stats[r.URL.Path].Requests = append(s.stats[r.URL.Path].Requests, newRequest(r)) + + if resp.LastModified != nil { + w.Header().Set("Last-Modified", resp.LastModified.Format(time.RFC850)) + } + + if resp.ContentEncoding != "" { + w.Header().Set("Content-Encoding", resp.ContentEncoding) + } + + if resp.ContentDisposition != "" { + w.Header().Set("Content-Disposition", resp.ContentDisposition) + } + + if resp.Etag != "" { + w.Header().Set("ETag", resp.Etag) + if match := r.Header.Get("If-None-Match"); match == resp.Etag { + w.WriteHeader(http.StatusNotModified) + s.stats[r.URL.Path].CachedRequests++ + s.mu.Unlock() + return + } + } + + s.mu.Unlock() + + w.WriteHeader(http.StatusOK) + io.Copy(w, bytes.NewReader(resp.Content)) +} + +func (s *TestServer) Stats(name string) (st Stat) { + if st, ok := s.stats[name]; ok { + return *st + } + return +} + +type Response struct { + Content []byte + Etag string + LastModified *time.Time + ContentEncoding string + ContentDisposition string +} + +type Stat struct { + AllRequests, CachedRequests int + Requests []Request +} + +type Request struct { + Method string + Header http.Header +} + +func newRequest(r *http.Request) Request { + headers := http.Header{} + for k, v := range r.Header { + headers[k] = slices.Clone(v) + } + return Request{ + Method: r.Method, + Header: headers, + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 16d39ba07900..02afd71de0e6 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -733,6 +733,7 @@ github.com/moby/buildkit/util/system github.com/moby/buildkit/util/testutil github.com/moby/buildkit/util/testutil/dockerd github.com/moby/buildkit/util/testutil/dockerd/client +github.com/moby/buildkit/util/testutil/httpserver github.com/moby/buildkit/util/testutil/integration github.com/moby/buildkit/util/testutil/workers github.com/moby/buildkit/util/tracing