diff --git a/build/build.go b/build/build.go index 9ae6bfc9583c..a09f4d982b8d 100644 --- a/build/build.go +++ b/build/build.go @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "io" - "io/fs" "maps" "os" "slices" @@ -22,7 +21,6 @@ import ( noderesolver "github.com/docker/buildx/build/resolver" "github.com/docker/buildx/builder" "github.com/docker/buildx/driver" - "github.com/docker/buildx/policy" "github.com/docker/buildx/util/buildflags" "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/desktop" @@ -120,13 +118,24 @@ type Inputs struct { policy *policyOpt } -type policyOpt struct { - Files []policy.File - FS func() (fs.StatFS, func() error, error) +type policyFileSpec struct { + Filename string + Optional bool + Data []byte +} + +type policyEvalOpt struct { Strict bool LogLevel *logrus.Level } +type policyOpt struct { + Files []policyFileSpec + ContextDir string + ContextState *llb.State + policyEvalOpt +} + func withPolicyConfig(defaultPolicy policyOpt, configs []buildflags.PolicyConfig) ([]policyOpt, error) { if len(configs) == 0 { if len(defaultPolicy.Files) == 0 { @@ -176,7 +185,10 @@ func withPolicyConfig(defaultPolicy policyOpt, configs []buildflags.PolicyConfig } opt := policyOpt{ - Files: cfg.Files, + Files: make([]policyFileSpec, 0, len(cfg.Files)), + } + for _, f := range cfg.Files { + opt.Files = append(opt.Files, policyFileSpec{Filename: f.Filename}) } if last.Strict != nil { opt.Strict = *last.Strict @@ -190,7 +202,8 @@ func withPolicyConfig(defaultPolicy policyOpt, configs []buildflags.PolicyConfig if cfg.LogLevel != nil { opt.LogLevel = cfg.LogLevel } - opt.FS = defaultPolicy.FS + opt.ContextDir = defaultPolicy.ContextDir + opt.ContextState = defaultPolicy.ContextState out = append(out, opt) } diff --git a/build/opt.go b/build/opt.go index 2fbf45f3d882..4005d9e09046 100644 --- a/build/opt.go +++ b/build/opt.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "io" - "io/fs" "maps" "os" "path" @@ -495,90 +494,11 @@ func toSolveOpt(ctx context.Context, np *noderesolver.ResolvedNode, multiDriver releaseLoad() }) - if opt.Inputs.policy == nil { - if len(opt.Policy) > 0 { - return nil, nil, errors.New("policy file specified but no policy FS in build context") - } - } else { - env := policy.Env{} - for k, v := range opt.BuildArgs { - if env.Args == nil { - env.Args = map[string]*string{} - } - env.Args[k] = &v - } - env.Filename = path.Base(opt.Inputs.DockerfilePath) - env.Target = opt.Target - env.Labels = opt.Labels - - popts, err := withPolicyConfig(*opt.Inputs.policy, opt.Policy) - if err != nil { - return nil, nil, err - } - var sourceResolver *sourcemeta.Resolver - if len(popts) > 0 { - c, err := np.Client(ctx) - if err != nil { - return nil, nil, err - } - sourceResolver = sourcemeta.NewResolver(c, sourcemeta.WithProgressWriter(pw)) - defers = append(defers, func(error) { - _ = sourceResolver.Close() - }) - } - var policyFiles []string - for _, popt := range popts { - for _, f := range popt.Files { - if f.Filename != "" { - policyFiles = append(policyFiles, f.Filename) - } - } - } - var policyLogger *policyProgressLogger - if len(policyFiles) > 0 { - policyLogger = newPolicyProgressLogger(pw, fmt.Sprintf("loading policies %s", strings.Join(policyFiles, ", "))) - } - var policies []*policy.Policy - if policyLogger != nil { - defers = append(defers, func(inErr error) { - if len(policysession.DenyMessages(inErr)) > 0 || isPolicyEvaluationError(policies, inErr) { - policyLogger.Close(inErr) - return - } - policyLogger.Close(nil) - }) - } - var cbs []policysession.PolicyCallback - for _, popt := range popts { - policyLevel := logrus.GetLevel() - if popt.LogLevel != nil { - policyLevel = *popt.LogLevel - } - logf := func(level logrus.Level, msg string) { - if policyLogger == nil || level > policyLevel { - return - } - policyLogger.Log(msg) - } - p := policy.NewPolicy(policy.Opt{ - Files: popt.Files, - Env: env, - Log: logf, - FS: opt.Inputs.policy.FS, - VerifierProvider: policy.SignatureVerifier(cfg), - DefaultPlatform: defaultPlatform(bopts), - SourceResolver: sourceResolver, - }) - policies = append(policies, p) - cbs = append(cbs, p.CheckPolicy) - if popt.Strict { - if bopts.LLBCaps.Supports(pb.CapSourcePolicySession) != nil { - return nil, nil, errors.New("strict policy is not supported by the current BuildKit daemon, please upgrade to version v0.27+") - } - } - } - so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(cbs...)) + policyDefers, err := configureSourcePolicy(ctx, np, opt, cfg, bopts, &so, pw) + if err != nil { + return nil, nil, err } + defers = append(defers, policyDefers...) // add node identifier to shared key if one was specified nodeID := cfg.TryNodeIdentifier() @@ -667,6 +587,114 @@ func toSolveOpt(ctx context.Context, np *noderesolver.ResolvedNode, multiDriver return &so, releaseF, nil } +func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, opt *Options, cfg *confutil.Config, bopts gateway.BuildOpts, so *client.SolveOpt, pw progress.Writer) (defers []func(error), err error) { + if opt.Inputs.policy == nil { + if len(opt.Policy) > 0 { + return nil, errors.New("policy file specified but no policy FS in build context") + } + so.SourcePolicyProvider = nil + return nil, nil + } + + env := policy.Env{} + for k, v := range opt.BuildArgs { + if env.Args == nil { + env.Args = map[string]*string{} + } + env.Args[k] = &v + } + env.Filename = path.Base(opt.Inputs.DockerfilePath) + env.Target = opt.Target + env.Labels = opt.Labels + + popts, err := withPolicyConfig(*opt.Inputs.policy, opt.Policy) + if err != nil { + return nil, err + } + if len(popts) == 0 { + so.SourcePolicyProvider = nil + return nil, nil + } + + c, err := np.Client(ctx) + if err != nil { + return nil, err + } + sourceResolver := sourcemeta.NewResolver(c, sourcemeta.WithProgressWriter(pw)) + defers = []func(error){ + func(error) { + _ = sourceResolver.Close() + }, + } + defer func() { + if err == nil { + return + } + for _, f := range defers { + f(err) + } + defers = nil + }() + + loadedOpts, err := resolvePolicyOpts(ctx, popts, sourceResolver) + if err != nil { + return nil, err + } + var policyFiles []string + for _, popt := range loadedOpts { + for _, f := range popt.Files { + if f.Filename != "" { + policyFiles = append(policyFiles, f.Filename) + } + } + } + var policyLogger *policyProgressLogger + if len(policyFiles) > 0 { + policyLogger = newPolicyProgressLogger(pw, fmt.Sprintf("loading policies %s", strings.Join(policyFiles, ", "))) + } + var policies []*policy.Policy + if policyLogger != nil { + defers = append(defers, func(inErr error) { + if len(policysession.DenyMessages(inErr)) > 0 || isPolicyEvaluationError(policies, inErr) { + policyLogger.Close(inErr) + return + } + policyLogger.Close(nil) + }) + } + var cbs []policysession.PolicyCallback + for _, popt := range loadedOpts { + policyLevel := logrus.GetLevel() + if popt.LogLevel != nil { + policyLevel = *popt.LogLevel + } + logf := func(level logrus.Level, msg string) { + if policyLogger == nil || level > policyLevel { + return + } + policyLogger.Log(msg) + } + p := policy.NewPolicy(policy.Opt{ + Files: popt.Files, + Env: env, + Log: logf, + FS: popt.FS, + VerifierProvider: policy.SignatureVerifier(cfg), + DefaultPlatform: defaultPlatform(bopts), + SourceResolver: sourceResolver, + }) + policies = append(policies, p) + cbs = append(cbs, p.CheckPolicy) + if popt.Strict { + if bopts.LLBCaps.Supports(pb.CapSourcePolicySession) != nil { + return nil, errors.New("strict policy is not supported by the current BuildKit daemon, please upgrade to version v0.27+") + } + } + } + so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(cbs...)) + return defers, nil +} + func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw progress.Writer, target *client.SolveOpt) (func(), error) { if inp.ContextPath == "" { return nil, errors.New("please specify build context (e.g. \".\" for the current directory)") @@ -678,6 +706,8 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro err error dockerfileReader io.ReadCloser contextDir string + remoteContext bool + remotePolicyState *llb.State dockerfileDir string dockerfileName = inp.DockerfilePath dockerfileSrcName = inp.DockerfilePath @@ -687,6 +717,7 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro switch { case inp.ContextState != nil: + remotePolicyState = inp.ContextState if target.FrontendInputs == nil { target.FrontendInputs = make(map[string]llb.State) } @@ -745,6 +776,7 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro dockerfileName = filepath.Base(inp.DockerfilePath) } case urlutil.IsRemoteURL(inp.ContextPath): + remoteContext = true if inp.DockerfilePath == "-" { dockerfileReader = inp.InStream.NewReadCloser() } else if filepath.IsAbs(inp.DockerfilePath) { @@ -758,6 +790,7 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro return nil, err } if st, ok := target.FrontendInputs["context"]; ok { + remotePolicyState = &st if dockerfileReader == nil && !filepath.IsAbs(inp.DockerfilePath) { target.FrontendInputs["dockerfile"] = st } @@ -801,22 +834,11 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro } p := &policyOpt{ - FS: func() (fs.StatFS, func() error, error) { - if contextDir == "" { - return nil, nil, errors.Errorf("unimplemented, cannot use policy file without a local build context") - } - root, err := os.OpenRoot(contextDir) - if err != nil { - return nil, nil, errors.Wrapf(err, "failed to open root for policy file %s.rego", dockerfileName) - } - baseFS := root.FS() - statFS, ok := baseFS.(fs.StatFS) - if !ok { - root.Close() - return nil, nil, errors.Errorf("invalid root FS type %T", baseFS) - } - return statFS, root.Close, nil - }, + ContextDir: contextDir, + } + p.ContextState = remotePolicyState + if p.ContextState == nil && remoteContext { + p.ContextState = resolveRemotePolicyContextState(inp.ContextPath, target) } if dockerfileDir != "" { @@ -824,22 +846,31 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro return nil, err } dockerfileName = handleLowercaseDockerfile(dockerfileDir, dockerfileName) - - if fi, err := os.Lstat(filepath.Join(dockerfileDir, dockerfileName+".rego")); err == nil { - if fi.Mode().IsRegular() { - dt, err := os.ReadFile(filepath.Join(dockerfileDir, dockerfileName+".rego")) - if err != nil { - return nil, errors.Wrapf(err, "failed to read policy file %s.rego", dockerfileName) - } - p.Files = []policy.File{ - { - Filename: dockerfileName + ".rego", - Data: dt, - }, - } + } + defaultPolicyFilename := dockerfileName + ".rego" + if dockerfileDir != "" { + defaultPolicyFilename = filepath.Join(dockerfileDir, defaultPolicyFilename) + } + defaultPolicy := policyFileSpec{ + Filename: defaultPolicyFilename, + Optional: true, + } + includeDefaultPolicy := true + if dockerfileDir != "" && p.ContextState == nil { + dt, err := os.ReadFile(defaultPolicyFilename) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, errors.Wrapf(err, "failed to read policy file %s", defaultPolicyFilename) } + includeDefaultPolicy = false + } else { + defaultPolicy.Data = dt } } + if includeDefaultPolicy { + p.Files = append(p.Files, defaultPolicy) + } + inp.policy = p target.FrontendAttrs["filename"] = dockerfileName @@ -928,6 +959,28 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro return release, nil } +func resolveRemotePolicyContextState(contextPath string, target *client.SolveOpt) *llb.State { + if target != nil && target.FrontendInputs != nil { + if st, ok := target.FrontendInputs["context"]; ok { + return &st + } + } + + keepGitDir := false + if st, ok, _ := dockerui.DetectGitContext(contextPath, &keepGitDir); ok { + return st + } + + st, filename, ok := dockerui.DetectHTTPContext(contextPath) + if !ok || filename == "" { + return nil + } + bc := llb.Scratch().File(llb.Copy(*st, filename, "/", &llb.CopyInfo{ + AttemptUnpack: true, + })) + return &bc +} + func resolveDigest(localPath, tag string) (dig string, _ error) { idx := ociindex.NewStoreIndex(localPath) diff --git a/build/policy_loader.go b/build/policy_loader.go new file mode 100644 index 000000000000..f12835ec42e7 --- /dev/null +++ b/build/policy_loader.go @@ -0,0 +1,391 @@ +package build + +import ( + "bytes" + "context" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/docker/buildx/policy" + "github.com/docker/buildx/util/sourcemeta" + "github.com/moby/buildkit/client/llb" + gwclient "github.com/moby/buildkit/frontend/gateway/client" + "github.com/pkg/errors" + "github.com/tonistiigi/fsutil/types" +) + +type loadedPolicyOpt struct { + Files []policy.File + FS func() (fs.StatFS, func() error, error) + policyEvalOpt +} + +func resolvePolicyOpts(ctx context.Context, in []policyOpt, resolver *sourcemeta.Resolver) ([]loadedPolicyOpt, error) { + if len(in) == 0 { + return nil, nil + } + + out := make([]loadedPolicyOpt, 0, len(in)) + for _, popt := range in { + provider := newPolicyPathFS(ctx, resolver, popt) + loaded := loadedPolicyOpt{ + policyEvalOpt: popt.policyEvalOpt, + FS: provider, + } + for _, f := range popt.Files { + if f.Data != nil { + loaded.Files = append(loaded.Files, policy.File{ + Filename: f.Filename, + Data: f.Data, + }) + continue + } + dt, ok, err := loadPolicyData(provider, f.Filename) + if err != nil { + return nil, err + } + if !ok { + if f.Optional { + continue + } + return nil, errors.Errorf("policy file %s not found", f.Filename) + } + loaded.Files = append(loaded.Files, policy.File{ + Filename: f.Filename, + Data: dt, + }) + } + if len(loaded.Files) > 0 { + out = append(out, loaded) + } + } + + return out, nil +} + +func loadPolicyData(provider func() (fs.StatFS, func() error, error), filename string) ([]byte, bool, error) { + root, closeFS, err := provider() + if err != nil { + return nil, false, errors.Wrapf(err, "failed to get policy FS for %s", filename) + } + if closeFS != nil { + defer closeFS() + } + if root == nil { + return nil, false, nil + } + if _, err := root.Stat(filename); err != nil { + if isFileNotFoundError(err) { + return nil, false, nil + } + return nil, false, errors.Wrapf(err, "failed to stat policy file %s", filename) + } + dt, err := fs.ReadFile(root, filename) + if err != nil { + if isFileNotFoundError(err) { + return nil, false, nil + } + return nil, false, errors.Wrapf(err, "failed to read policy file %s", filename) + } + return dt, true, nil +} + +type policyPathFS struct { + ctx context.Context + resolver *sourcemeta.Resolver + contextDir string + contextState *llb.State + + cwdFS memoizedPolicyFS + contextFS memoizedPolicyFS +} + +func newPolicyPathFS(ctx context.Context, resolver *sourcemeta.Resolver, popt policyOpt) func() (fs.StatFS, func() error, error) { + p := &policyPathFS{ + ctx: context.WithoutCancel(ctx), + resolver: resolver, + contextDir: popt.ContextDir, + contextState: popt.ContextState, + } + + p.cwdFS.init = func() (fs.StatFS, func() error, error) { + root, err := os.OpenRoot(".") + if err != nil { + return nil, nil, err + } + baseFS := root.FS() + statFS, ok := baseFS.(fs.StatFS) + if !ok { + root.Close() + return nil, nil, errors.Errorf("invalid root FS type %T", baseFS) + } + return statFS, root.Close, nil + } + + p.contextFS.init = func() (fs.StatFS, func() error, error) { + if p.contextState != nil { + if resolver == nil { + return nil, nil, errors.New("policy resolver is not configured") + } + return newRemotePolicyFS(p.ctx, resolver, *p.contextState), nil, nil + } + if p.contextDir == "" { + return nil, nil, nil + } + root, err := os.OpenRoot(p.contextDir) + if err != nil { + return nil, nil, err + } + baseFS := root.FS() + statFS, ok := baseFS.(fs.StatFS) + if !ok { + root.Close() + return nil, nil, errors.Errorf("invalid root FS type %T", baseFS) + } + return statFS, root.Close, nil + } + + return func() (fs.StatFS, func() error, error) { + return p, p.Close, nil + } +} + +func (p *policyPathFS) Open(name string) (fs.File, error) { + backend, target, err := p.resolve(name) + if err != nil { + return nil, err + } + if backend == nil { + return nil, fs.ErrNotExist + } + return backend.Open(target) +} + +func (p *policyPathFS) Stat(name string) (fs.FileInfo, error) { + backend, target, err := p.resolve(name) + if err != nil { + return nil, err + } + if backend == nil { + return nil, fs.ErrNotExist + } + return backend.Stat(target) +} + +func (p *policyPathFS) Close() error { + if err := p.cwdFS.close(); err != nil { + return err + } + return p.contextFS.close() +} + +func (p *policyPathFS) resolve(name string) (fs.StatFS, string, error) { + if name == "" { + return nil, "", errors.New("policy filename is empty") + } + if v, ok := strings.CutPrefix(name, "cwd://"); ok { + if v == "" { + return nil, "", errors.Errorf("invalid policy filename %q", name) + } + cwd, err := p.cwdFS.get() + if err != nil { + return nil, "", err + } + return cwd, filepath.Clean(v), nil + } + + contextFS, err := p.contextFS.get() + if err != nil { + return nil, "", err + } + if p.contextState != nil { + target, err := normalizeRemotePolicyPath(name) + if err != nil { + return nil, "", err + } + return contextFS, target, nil + } + return contextFS, normalizeLocalPolicyPath(name, p.contextDir), nil +} + +func normalizeLocalPolicyPath(name, contextDir string) string { + if filepath.IsAbs(name) && contextDir != "" { + if rel, err := filepath.Rel(contextDir, name); err == nil { + rel = filepath.Clean(rel) + if rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return rel + } + } + } + return filepath.Clean(name) +} + +type memoizedPolicyFS struct { + init func() (fs.StatFS, func() error, error) + once sync.Once + fs fs.StatFS + closeFn func() error + err error +} + +func (m *memoizedPolicyFS) get() (fs.StatFS, error) { + m.once.Do(func() { + if m.init == nil { + return + } + m.fs, m.closeFn, m.err = m.init() + }) + if m.err != nil { + return nil, m.err + } + return m.fs, nil +} + +func (m *memoizedPolicyFS) close() error { + if m.closeFn != nil { + return m.closeFn() + } + return nil +} + +func normalizeRemotePolicyPath(raw string) (string, error) { + clean := strings.TrimPrefix(path.Join("/", filepath.ToSlash(raw)), "/") + if clean == "." || clean == "" { + return "", errors.Errorf("invalid remote policy filename %q", raw) + } + return clean, nil +} + +func isFileNotFoundError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, fs.ErrNotExist) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "not found") || strings.Contains(msg, "no such file") +} + +type remotePolicyFS struct { + ctx context.Context + resolver *sourcemeta.Resolver + state llb.State + + once sync.Once + ref gwclient.Reference + err error +} + +func newRemotePolicyFS(ctx context.Context, resolver *sourcemeta.Resolver, state llb.State) *remotePolicyFS { + return &remotePolicyFS{ + ctx: context.WithoutCancel(ctx), + resolver: resolver, + state: state, + } +} + +func (r *remotePolicyFS) Open(name string) (fs.File, error) { + p, err := normalizeRemotePolicyPath(name) + if err != nil { + return nil, err + } + ref, err := r.resolveRef() + if err != nil { + return nil, err + } + + st, err := ref.StatFile(r.ctx, gwclient.StatRequest{Path: p}) + if err != nil { + return nil, err + } + dt, err := ref.ReadFile(r.ctx, gwclient.ReadRequest{Filename: p}) + if err != nil { + return nil, err + } + fi := policyFileInfo{ + name: path.Base(p), + size: int64(len(dt)), + mode: fs.FileMode(st.Mode), + tm: time.Unix(0, st.ModTime), + } + if fi.size == 0 { + fi.size = st.Size + } + + return &policyReadFile{ + Reader: bytes.NewReader(dt), + info: fi, + }, nil +} + +func (r *remotePolicyFS) Stat(name string) (fs.FileInfo, error) { + p, err := normalizeRemotePolicyPath(name) + if err != nil { + return nil, err + } + ref, err := r.resolveRef() + if err != nil { + return nil, err + } + st, err := ref.StatFile(r.ctx, gwclient.StatRequest{Path: p}) + if err != nil { + return nil, err + } + return policyFileInfo{ + name: path.Base(p), + size: st.Size, + mode: fs.FileMode(st.Mode), + tm: time.Unix(0, st.ModTime), + }, nil +} + +func (r *remotePolicyFS) resolveRef() (gwclient.Reference, error) { + r.once.Do(func() { + r.ref, r.err = r.resolver.ResolveState(r.ctx, r.state) + }) + if r.err != nil { + return nil, r.err + } + return r.ref, nil +} + +type policyReadFile struct { + *bytes.Reader + info policyFileInfo +} + +func (f *policyReadFile) Stat() (fs.FileInfo, error) { + return f.info, nil +} + +func (f *policyReadFile) Close() error { + return nil +} + +type policyFileInfo struct { + name string + size int64 + mode fs.FileMode + tm time.Time +} + +func (i policyFileInfo) Name() string { return i.name } +func (i policyFileInfo) Size() int64 { return i.size } +func (i policyFileInfo) Mode() fs.FileMode { return i.mode } +func (i policyFileInfo) ModTime() time.Time { return i.tm } +func (i policyFileInfo) IsDir() bool { return i.mode.IsDir() } +func (i policyFileInfo) Sys() any { + return &types.Stat{Mode: uint32(i.mode), Size: i.size, ModTime: i.tm.UnixNano()} +} + +var _ fs.StatFS = (*policyPathFS)(nil) +var _ fs.StatFS = (*remotePolicyFS)(nil) +var _ fs.File = (*policyReadFile)(nil) +var _ io.ReaderAt = (*bytes.Reader)(nil) diff --git a/build/policy_test.go b/build/policy_test.go index 33760725dd4e..c4ea5476a265 100644 --- a/build/policy_test.go +++ b/build/policy_test.go @@ -1,7 +1,6 @@ package build import ( - "io/fs" "testing" "github.com/docker/buildx/policy" @@ -21,11 +20,8 @@ func levelPtr(v logrus.Level) *logrus.Level { // 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 + Files: []policyFileSpec{ + {Filename: "default.rego", Optional: true}, }, } @@ -35,7 +31,6 @@ func TestWithPolicyConfigDefaults(t *testing.T) { require.Equal(t, defaultPolicy.Files, out[0].Files) require.False(t, out[0].Strict) require.Nil(t, out[0].LogLevel) - require.NotNil(t, out[0].FS) } // TestWithPolicyConfigDisabled validates disabled policy behavior across invalid and valid combinations. @@ -76,10 +71,7 @@ func TestWithPolicyConfigDisabled(t *testing.T) { // 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 - }, + Files: []policyFileSpec{{Filename: "default.rego", Optional: true}}, } out, err := withPolicyConfig(defaultPolicy, []buildflags.PolicyConfig{ @@ -89,13 +81,13 @@ func TestWithPolicyConfigResetAndFiles(t *testing.T) { 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) + require.False(t, out[0].Files[0].Optional) } // TestWithPolicyConfigStrictAndLogLevel ensures strict and log level apply to existing policy. func TestWithPolicyConfigStrictAndLogLevel(t *testing.T) { defaultPolicy := policyOpt{ - Files: []policy.File{{Filename: "default.rego"}}, + Files: []policyFileSpec{{Filename: "default.rego", Optional: true}}, } out, err := withPolicyConfig(defaultPolicy, []buildflags.PolicyConfig{ @@ -120,10 +112,7 @@ func TestWithPolicyConfigStrictIgnoredWithoutPolicy(t *testing.T) { // 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 - }, + Files: []policyFileSpec{{Filename: "default.rego", Optional: true}}, } out, err := withPolicyConfig(defaultPolicy, []buildflags.PolicyConfig{ @@ -134,12 +123,13 @@ func TestWithPolicyConfigMultipleFilesAndOverrides(t *testing.T) { require.NoError(t, err) require.Len(t, out, 3) require.Equal(t, "default.rego", out[0].Files[0].Filename) + require.True(t, out[0].Files[0].Optional) require.Equal(t, "a.rego", out[1].Files[0].Filename) + require.False(t, out[1].Files[0].Optional) require.True(t, out[1].Strict) require.NotNil(t, out[1].LogLevel) require.Equal(t, logrus.WarnLevel, *out[1].LogLevel) require.Equal(t, "b.rego", out[2].Files[0].Filename) + require.False(t, out[2].Files[0].Optional) require.True(t, out[2].Strict) - require.NotNil(t, out[1].FS) - require.NotNil(t, out[2].FS) } diff --git a/tests/policy_build.go b/tests/policy_build.go index c04bd1a98a5b..244b8e00d492 100644 --- a/tests/policy_build.go +++ b/tests/policy_build.go @@ -1,6 +1,8 @@ package tests import ( + "archive/tar" + "bytes" "encoding/json" "errors" "fmt" @@ -33,6 +35,8 @@ var policyBuildTests = []func(t *testing.T, sb integration.Sandbox){ testBuildPolicyEnv, testBuildPolicyHTTP, testBuildPolicyGit, + testBuildPolicyRemotePolicyFiles, + testBuildPolicyRemoteHTTPPolicyFiles, testBuildPolicyConfigFlags, } @@ -1089,7 +1093,7 @@ decision := {"allow": allow} t, fstest.CreateFile("policy.rego", []byte(tc.policy), 0600), ) - policyPath := filepath.Join(dir, "policy.rego") + policyPath := "cwd://policy.rego" cmd := buildxCmd(sb, withDir(dir), withArgs( "build", @@ -1257,3 +1261,303 @@ decision := {"allow": allow} require.Contains(t, string(out), "disabled policy cannot be combined with other policy flags") }) } + +func testBuildPolicyRemotePolicyFiles(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 remote-policy\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(gitDir, "policy"), 0700)) + require.NoError(t, os.MkdirAll(filepath.Join(gitDir, "hack"), 0700)) + + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "policy", "import.rego"), []byte(` +package docker + +import data.hack.allow as allowlib + +default allow = false + +allow if allowlib.required + +decision := {"allow": allow} +`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "hack", "allow.rego"), []byte(` +package hack.allow + +required := true +`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "policy", "json.rego"), []byte(` +package docker + +default allow = false + +cfg := load_json("policy/config.json") + +allow if cfg.allow == true + +decision := {"allow": allow} +`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "policy", "config.json"), []byte(`{"allow": true}`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "policy", "deny.rego"), []byte(` +package docker + +default allow = false + +decision := {"allow": allow} +`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "remote-only.rego"), []byte(` +package docker + +default allow = false + +decision := {"allow": allow} +`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "Dockerfile.rego"), []byte(` +package docker + +default allow = true + +decision := {"allow": allow} +`), 0600)) + + git, err := gitutil.New(gitutil.WithWorkingDir(gitDir)) + require.NoError(t, err) + gittestutil.GitInit(git, t) + gittestutil.GitAdd(git, t, ".") + gittestutil.GitCommit(git, t, "initial commit") + baseURL := gittestutil.GitServeHTTP(git, t) + + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "local-allow.rego"), []byte(` +package docker + +default allow = true + +decision := {"allow": allow} +`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "local-only.rego"), []byte(` +package docker + +default allow = true + +decision := {"allow": allow} +`), 0600)) + + t.Run("remote-policy-import", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--policy", "filename=policy/import.rego", + "--output=type=cacheonly", + baseURL, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies") + require.Contains(t, string(out), "policy/import.rego") + }) + + t.Run("remote-policy-load-json", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--policy", "filename=policy/json.rego", + "--output=type=cacheonly", + baseURL, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies") + require.Contains(t, string(out), "policy/json.rego") + }) + + t.Run("remote-policy-cwd-override", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--policy", "filename=cwd://local-allow.rego", + "--output=type=cacheonly", + baseURL, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies") + require.Contains(t, string(out), "cwd://local-allow.rego") + }) + + t.Run("remote-policy-no-local-fallback", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--policy", "filename=does-not-exist.rego", + "--output=type=cacheonly", + baseURL, + )) + out, err := cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "policy file does-not-exist.rego not found") + }) + + t.Run("remote-policy-prefers-remote-over-cwd", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--policy", "filename=remote-only.rego", + "--output=type=cacheonly", + baseURL, + )) + out, err := cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "not allowed by policy") + require.Contains(t, string(out), "loading policies") + require.Contains(t, string(out), "remote-only.rego") + }) + + t.Run("remote-policy-cwd-prefix-uses-local", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--policy", "filename=cwd://local-only.rego", + "--output=type=cacheonly", + baseURL, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies") + require.Contains(t, string(out), "cwd://local-only.rego") + }) + + t.Run("remote-policy-default-from-remote-git", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--output=type=cacheonly", + baseURL, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies Dockerfile.rego") + }) +} + +func testBuildPolicyRemoteHTTPPolicyFiles(t *testing.T, sb integration.Sandbox) { + skipNoCompatBuildKit(t, sb, ">= 0.26.0-0", "policy input requires BuildKit v0.26.0+") + + makeTar := func(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for name, data := range files { + dt := []byte(data) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0600, + Size: int64(len(dt)), + })) + _, err := tw.Write(dt) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + return buf.Bytes() + } + + archiveURLPath := "/context.tar" + singleURLPath := "/Dockerfile" + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + archiveURLPath: { + Content: makeTar(t, map[string]string{ + "Dockerfile": `FROM busybox:latest +RUN echo http-archive-policy +`, + "policy/allow.rego": ` +package docker + +default allow = true + +decision := {"allow": allow} +`, + }), + }, + singleURLPath: { + Content: []byte("FROM busybox:latest\nRUN echo http-single-file\n"), + }, + }) + defer server.Close() + + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "Dockerfile.rego"), []byte(` +package docker + +default allow = false + +decision := {"allow": allow} +`), 0600)) + + t.Run("http-archive-policy-file", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--policy", "filename=policy/allow.rego", + "--output=type=cacheonly", + server.URL+archiveURLPath, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies") + require.Contains(t, string(out), "policy/allow.rego") + }) + + t.Run("http-single-file-required-policy-not-found", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--policy", "filename=policy/allow.rego", + "--output=type=cacheonly", + server.URL+singleURLPath, + )) + out, err := cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "policy file policy/allow.rego not found") + }) + + t.Run("http-single-file-no-local-default-fallback", func(t *testing.T) { + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--output=type=cacheonly", + server.URL+singleURLPath, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + }) + + t.Run("http-archive-default-from-remote-context", func(t *testing.T) { + serverWithDefault := httpserver.NewTestServer(map[string]*httpserver.Response{ + archiveURLPath: { + Content: makeTar(t, map[string]string{ + "Dockerfile": `FROM busybox:latest +RUN echo http-archive-default +`, + "Dockerfile.rego": ` +package docker + +default allow = true + +decision := {"allow": allow} +`, + }), + }, + }) + defer serverWithDefault.Close() + + cmd := buildxCmd(sb, withDir(workDir), withArgs( + "build", + "--progress=plain", + "--output=type=cacheonly", + serverWithDefault.URL+archiveURLPath, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), "loading policies Dockerfile.rego") + }) +} diff --git a/util/buildflags/policy.go b/util/buildflags/policy.go index 00d327c01347..0d1abb694b53 100644 --- a/util/buildflags/policy.go +++ b/util/buildflags/policy.go @@ -1,7 +1,6 @@ package buildflags import ( - "os" "strconv" "strings" @@ -56,11 +55,7 @@ func parsePolicyFields(fields []string) (PolicyConfig, error) { 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}) + cfg.Files = append(cfg.Files, policy.File{Filename: value}) case "reset": b, err := strconv.ParseBool(value) if err != nil { diff --git a/util/buildflags/policy_test.go b/util/buildflags/policy_test.go index ff678a89a334..9359c7690dab 100644 --- a/util/buildflags/policy_test.go +++ b/util/buildflags/policy_test.go @@ -14,8 +14,7 @@ import ( 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)) + require.NoError(t, os.WriteFile(policyPath, []byte("package docker\n"), 0o600)) in := cty.TupleVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ @@ -33,7 +32,7 @@ func TestPolicyConfigs_FromCtyValue(t *testing.T) { require.Len(t, actual, 2) require.Equal(t, policyPath, actual[0].Files[0].Filename) - require.Equal(t, policyData, actual[0].Files[0].Data) + require.Nil(t, actual[0].Files[0].Data) require.True(t, actual[0].Reset) require.NotNil(t, actual[0].Strict) require.True(t, *actual[0].Strict) @@ -41,7 +40,7 @@ func TestPolicyConfigs_FromCtyValue(t *testing.T) { 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.Nil(t, actual[1].Files[0].Data) require.True(t, actual[1].Disabled) } @@ -82,13 +81,12 @@ func TestPolicyConfigs_ToCtyValue(t *testing.T) { 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)) + require.NoError(t, os.WriteFile(policyPath, []byte("package docker\n"), 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.Nil(t, actual.Files[0].Data) require.True(t, actual.Disabled) }