policy: separate sourcemetaresolver utility to reusable pkg

Turn the current utility used by policy helper commands that
allows getting gateway.Client lazily into reusable package.

This is preparation for using this utility also in the policy
verification, outside of commands.

Only expose SourceMetaResolver as other methods of gateway client
are not needed atm by any callers.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2026-02-13 13:59:33 -08:00
parent 044e152008
commit dce5289722
6 changed files with 350 additions and 132 deletions
+5 -16
View File
@@ -15,6 +15,7 @@ import (
"github.com/docker/buildx/builder" "github.com/docker/buildx/builder"
"github.com/docker/buildx/policy" "github.com/docker/buildx/policy"
"github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/confutil"
"github.com/docker/buildx/util/sourcemeta"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/moby/buildkit/client/llb/sourceresolver" "github.com/moby/buildkit/client/llb/sourceresolver"
"github.com/moby/buildkit/frontend/dockerui" "github.com/moby/buildkit/frontend/dockerui"
@@ -96,11 +97,8 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva
OS: defaultPlatform.OS, OS: defaultPlatform.OS,
Variant: defaultPlatform.Variant, Variant: defaultPlatform.Variant,
} }
openClient, release, err := gatewayClientFactory(c) metaResolver := sourcemeta.NewResolver(c)
if err != nil { defer metaResolver.Close()
return err
}
defer release()
platform := &pb.Platform{ platform := &pb.Platform{
Architecture: p.Architecture, Architecture: p.Architecture,
@@ -148,13 +146,8 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva
if err := policy.AddUnknowns(req, toReload); err != nil { if err := policy.AddUnknowns(req, toReload); err != nil {
return err return err
} }
gwClient, err := openClient(ctx)
if err != nil {
return err
}
opt := sourceResolverOpt(req, &p) opt := sourceResolverOpt(req, &p)
resp, err := gwClient.ResolveSourceMetadata(ctx, src, opt) resp, err := metaResolver.ResolveSourceMetadata(ctx, src, opt)
if err != nil { if err != nil {
return err return err
} }
@@ -240,12 +233,8 @@ func runEval(ctx context.Context, dockerCli command.Cli, source string, opts eva
return evalDecisionError(decision) return evalDecisionError(decision)
} }
gwClient, err := openClient(ctx)
if err != nil {
return err
}
opt := sourceResolverOpt(next, &p) opt := sourceResolverOpt(next, &p)
resp, err := gwClient.ResolveSourceMetadata(ctx, src, opt) resp, err := metaResolver.ResolveSourceMetadata(ctx, src, opt)
if err != nil { if err != nil {
return err return err
} }
-80
View File
@@ -1,80 +0,0 @@
package policy
import (
"context"
"errors"
"sync"
"sync/atomic"
"github.com/moby/buildkit/client"
gwclient "github.com/moby/buildkit/frontend/gateway/client"
)
type gatewayClientOpener func(context.Context) (gwclient.Client, error)
func gatewayClientFactory(c *client.Client) (gatewayClientOpener, func() error, error) {
var (
once sync.Once
releaseOnce sync.Once
started atomic.Bool
ready = make(chan gwclient.Client, 1)
done = make(chan error, 1)
openErr error
releaseErr error
gwClient gwclient.Client
cancel context.CancelCauseFunc
)
open := func(ctx context.Context) (gwclient.Client, error) {
once.Do(func() {
started.Store(true)
buildCtx, cancelFn := context.WithCancelCause(ctx)
cancel = cancelFn
go func() {
_, err := c.Build(buildCtx, client.SolveOpt{Internal: true}, "buildx", func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
ready <- c
<-buildCtx.Done()
return nil, context.Cause(buildCtx)
}, nil)
done <- err
}()
select {
case gwClient = <-ready:
case err := <-done:
if err == nil {
err = errors.New("gateway build finished without a client")
}
openErr = err
case <-ctx.Done():
openErr = context.Cause(ctx)
cancelFn(openErr)
}
})
if openErr != nil {
return nil, openErr
}
return gwClient, nil
}
release := func() error {
releaseOnce.Do(func() {
if !started.Load() {
return
}
if cancel != nil {
cancel(context.Canceled)
}
err := <-done
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
releaseErr = err
})
return releaseErr
}
return open, release, nil
}
+21 -31
View File
@@ -14,6 +14,7 @@ import (
"github.com/docker/buildx/policy" "github.com/docker/buildx/policy"
"github.com/docker/buildx/util/cobrautil" "github.com/docker/buildx/util/cobrautil"
"github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/confutil"
"github.com/docker/buildx/util/sourcemeta"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
gwpb "github.com/moby/buildkit/frontend/gateway/pb" gwpb "github.com/moby/buildkit/frontend/gateway/pb"
"github.com/moby/buildkit/solver/pb" "github.com/moby/buildkit/solver/pb"
@@ -30,9 +31,9 @@ func testCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
DisableFlagsInUseLine: true, DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
resolver := newPolicyTestResolver(dockerCli, rootOpts.Builder) optionsProvider := newPolicyTestOptionsProvider(dockerCli, rootOpts.Builder)
opts.Resolver = resolver.Options() opts.Provider = optionsProvider.TestOptionsProvider()
defer resolver.Close() defer optionsProvider.Close()
return runTest(cmd.Context(), cmd.OutOrStdout(), args[0], opts) return runTest(cmd.Context(), cmd.OutOrStdout(), args[0], opts)
}, },
} }
@@ -114,63 +115,58 @@ func withInputPrefix(keys []string) []string {
return out return out
} }
type policyTestResolver struct { type policyTestOptionsProvider struct {
dockerCli command.Cli dockerCli command.Cli
builderName *string builderName *string
once sync.Once once sync.Once
platform *ocispecs.Platform platform *ocispecs.Platform
openClient gatewayClientOpener metaResolver *sourcemeta.Resolver
release func() error err error
err error
} }
func newPolicyTestResolver(dockerCli command.Cli, builderName *string) *policyTestResolver { func newPolicyTestOptionsProvider(dockerCli command.Cli, builderName *string) *policyTestOptionsProvider {
return &policyTestResolver{ return &policyTestOptionsProvider{
dockerCli: dockerCli, dockerCli: dockerCli,
builderName: builderName, builderName: builderName,
} }
} }
func (r *policyTestResolver) Options() *policy.TestResolver { func (r *policyTestOptionsProvider) TestOptionsProvider() *policy.TestOptionsProvider {
return &policy.TestResolver{ return &policy.TestOptionsProvider{
Resolve: r.Resolve, Resolve: r.Resolve,
Platform: r.Platform, Platform: r.Platform,
VerifierProvider: policy.SignatureVerifier(confutil.NewConfig(r.dockerCli)), VerifierProvider: policy.SignatureVerifier(confutil.NewConfig(r.dockerCli)),
} }
} }
func (r *policyTestResolver) Close() error { func (r *policyTestOptionsProvider) Close() error {
if r.release == nil { if r.metaResolver == nil {
return nil return nil
} }
return r.release() return r.metaResolver.Close()
} }
func (r *policyTestResolver) Platform(ctx context.Context) (*ocispecs.Platform, error) { func (r *policyTestOptionsProvider) Platform(ctx context.Context) (*ocispecs.Platform, error) {
if err := r.init(ctx); err != nil { if err := r.init(ctx); err != nil {
return nil, err return nil, err
} }
return r.platform, nil return r.platform, nil
} }
func (r *policyTestResolver) Resolve(ctx context.Context, source *pb.SourceOp, req *gwpb.ResolveSourceMetaRequest) (*gwpb.ResolveSourceMetaResponse, error) { func (r *policyTestOptionsProvider) Resolve(ctx context.Context, source *pb.SourceOp, req *gwpb.ResolveSourceMetaRequest) (*gwpb.ResolveSourceMetaResponse, error) {
if err := r.init(ctx); err != nil { if err := r.init(ctx); err != nil {
return nil, err return nil, err
} }
gwClient, err := r.openClient(ctx)
if err != nil {
return nil, err
}
opt := sourceResolverOpt(req, r.platform) opt := sourceResolverOpt(req, r.platform)
resp, err := gwClient.ResolveSourceMetadata(ctx, source, opt) resp, err := r.metaResolver.ResolveSourceMetadata(ctx, source, opt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return buildSourceMetaResponse(resp), nil return buildSourceMetaResponse(resp), nil
} }
func (r *policyTestResolver) init(ctx context.Context) error { func (r *policyTestOptionsProvider) init(ctx context.Context) error {
r.once.Do(func() { r.once.Do(func() {
bopts := []builder.Option{} bopts := []builder.Option{}
if r.builderName != nil { if r.builderName != nil {
@@ -208,13 +204,7 @@ func (r *policyTestResolver) init(ctx context.Context) error {
OS: defaultPlatform.OS, OS: defaultPlatform.OS,
Variant: defaultPlatform.Variant, Variant: defaultPlatform.Variant,
} }
openClient, release, err := gatewayClientFactory(c) r.metaResolver = sourcemeta.NewResolver(c)
if err != nil {
r.err = err
return
}
r.openClient = openClient
r.release = release
}) })
return r.err return r.err
} }
+5 -5
View File
@@ -25,7 +25,7 @@ type TestOptions struct {
Run string Run string
Filename string Filename string
Root fs.StatFS Root fs.StatFS
Resolver *TestResolver Provider *TestOptionsProvider
} }
type TestSummary struct { type TestSummary struct {
@@ -50,7 +50,7 @@ type testDef struct {
PkgPath string PkgPath string
} }
type TestResolver struct { type TestOptionsProvider struct {
Resolve func(context.Context, *pb.SourceOp, *gwpb.ResolveSourceMetaRequest) (*gwpb.ResolveSourceMetaResponse, error) Resolve func(context.Context, *pb.SourceOp, *gwpb.ResolveSourceMetaRequest) (*gwpb.ResolveSourceMetaResponse, error)
Platform func(context.Context) (*ocispecs.Platform, error) Platform func(context.Context) (*ocispecs.Platform, error)
VerifierProvider PolicyVerifierProvider VerifierProvider PolicyVerifierProvider
@@ -286,8 +286,8 @@ func runPolicyTest(ctx context.Context, policyModules map[string]*ast.Module, te
return result, err return result, err
} }
effectiveInput := input effectiveInput := input
if opts.Resolver != nil { if opts.Provider != nil {
resolvedInput, ok, err := resolveTestInput(ctx, policyFiles, opts.Resolver, policyPackageModules, input, fsProvider) resolvedInput, ok, err := resolveTestInput(ctx, policyFiles, opts.Provider, policyPackageModules, input, fsProvider)
if err != nil { if err != nil {
return result, err return result, err
} }
@@ -397,7 +397,7 @@ func stateFromInput(input *Input) *state {
return st return st
} }
func resolveTestInput(ctx context.Context, files []File, resolver *TestResolver, policyModules []*ast.Module, input *Input, fsProvider func() (fs.StatFS, func() error, error)) (*Input, bool, error) { func resolveTestInput(ctx context.Context, files []File, resolver *TestOptionsProvider, policyModules []*ast.Module, input *Input, fsProvider func() (fs.StatFS, func() error, error)) (*Input, bool, error) {
if resolver == nil { if resolver == nil {
return nil, false, nil return nil, false, nil
} }
+126
View File
@@ -0,0 +1,126 @@
package sourcemeta
import (
"context"
"errors"
"sync"
"sync/atomic"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/client/llb/sourceresolver"
gwclient "github.com/moby/buildkit/frontend/gateway/client"
"github.com/moby/buildkit/solver/pb"
)
var _ sourceresolver.MetaResolver = &Resolver{}
type Resolver struct {
startOnce sync.Once
closeOnce sync.Once
started atomic.Bool
mu sync.Mutex
ready chan sourceresolver.MetaResolver
done chan struct{}
openErr error
doneErr error
cancel context.CancelCauseFunc
metaResolver sourceresolver.MetaResolver
run func(context.Context, chan<- sourceresolver.MetaResolver) error
closeErr error
}
func NewResolver(c *client.Client) *Resolver {
return newWithRun(func(ctx context.Context, ready chan<- sourceresolver.MetaResolver) error {
_, err := c.Build(ctx, client.SolveOpt{Internal: true}, "buildx", func(ctx context.Context, gw gwclient.Client) (*gwclient.Result, error) {
ready <- gw
<-ctx.Done()
return nil, context.Cause(ctx)
}, nil)
return err
})
}
func newWithRun(run func(context.Context, chan<- sourceresolver.MetaResolver) error) *Resolver {
return &Resolver{
ready: make(chan sourceresolver.MetaResolver, 1),
done: make(chan struct{}),
run: run,
}
}
func (r *Resolver) ResolveSourceMetadata(ctx context.Context, op *pb.SourceOp, opt sourceresolver.Opt) (*sourceresolver.MetaResponse, error) {
mr, err := r.open(ctx)
if err != nil {
return nil, err
}
return mr.ResolveSourceMetadata(ctx, op, opt)
}
func (r *Resolver) Close() error {
r.closeOnce.Do(func() {
if !r.started.Load() {
return
}
if r.cancel != nil {
r.cancel(context.Canceled)
}
<-r.done
err := r.doneErr
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
r.closeErr = err
})
return r.closeErr
}
func (r *Resolver) open(ctx context.Context) (sourceresolver.MetaResolver, error) {
r.startOnce.Do(func() {
r.started.Store(true)
buildCtx, cancel := context.WithCancelCause(context.Background())
r.cancel = cancel
go func() {
r.doneErr = r.run(buildCtx, r.ready)
close(r.done)
}()
})
for {
r.mu.Lock()
if r.metaResolver != nil {
mr := r.metaResolver
r.mu.Unlock()
return mr, nil
}
if r.openErr != nil {
err := r.openErr
r.mu.Unlock()
return nil, err
}
r.mu.Unlock()
select {
case mr := <-r.ready:
r.mu.Lock()
if r.metaResolver == nil {
r.metaResolver = mr
}
r.mu.Unlock()
case <-r.done:
r.mu.Lock()
if r.metaResolver == nil && r.openErr == nil {
err := r.doneErr
if err == nil {
err = errors.New("gateway build finished without a source metadata resolver")
}
r.openErr = err
}
r.mu.Unlock()
case <-ctx.Done():
return nil, context.Cause(ctx)
}
}
}
+193
View File
@@ -0,0 +1,193 @@
package sourcemeta
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/moby/buildkit/client/llb/sourceresolver"
"github.com/moby/buildkit/solver/pb"
"github.com/stretchr/testify/require"
)
type fakeMetaResolver struct {
calls atomic.Int32
resp *sourceresolver.MetaResponse
err error
}
func (f *fakeMetaResolver) ResolveSourceMetadata(ctx context.Context, op *pb.SourceOp, opt sourceresolver.Opt) (*sourceresolver.MetaResponse, error) {
f.calls.Add(1)
return f.resp, f.err
}
func TestResolverCloseNoopBeforeResolve(t *testing.T) {
t.Parallel()
var called atomic.Int32
r := newWithRun(func(ctx context.Context, ready chan<- sourceresolver.MetaResolver) error {
called.Add(1)
return nil
})
require.NoError(t, r.Close())
require.EqualValues(t, 0, called.Load())
}
func TestResolverResolveOpensOnce(t *testing.T) {
t.Parallel()
var runs atomic.Int32
mr := &fakeMetaResolver{resp: &sourceresolver.MetaResponse{}}
r := newWithRun(func(ctx context.Context, ready chan<- sourceresolver.MetaResolver) error {
runs.Add(1)
ready <- mr
<-ctx.Done()
return context.Cause(ctx)
})
op := &pb.SourceOp{}
_, err := r.ResolveSourceMetadata(t.Context(), op, sourceresolver.Opt{})
require.NoError(t, err)
_, err = r.ResolveSourceMetadata(t.Context(), op, sourceresolver.Opt{})
require.NoError(t, err)
require.EqualValues(t, 1, runs.Load())
require.EqualValues(t, 2, mr.calls.Load())
require.NoError(t, r.Close())
}
func TestResolverCloseAfterOpenCancelsBuild(t *testing.T) {
t.Parallel()
var canceled atomic.Bool
r := newWithRun(func(ctx context.Context, ready chan<- sourceresolver.MetaResolver) error {
ready <- &fakeMetaResolver{resp: &sourceresolver.MetaResponse{}}
<-ctx.Done()
canceled.Store(true)
return context.Cause(ctx)
})
_, err := r.ResolveSourceMetadata(t.Context(), &pb.SourceOp{}, sourceresolver.Opt{})
require.NoError(t, err)
require.NoError(t, r.Close())
require.True(t, canceled.Load())
}
func TestResolverOpenFailureIsSticky(t *testing.T) {
t.Parallel()
expected := errors.New("boom")
var runs atomic.Int32
r := newWithRun(func(ctx context.Context, ready chan<- sourceresolver.MetaResolver) error {
runs.Add(1)
return expected
})
_, err := r.ResolveSourceMetadata(t.Context(), &pb.SourceOp{}, sourceresolver.Opt{})
require.ErrorIs(t, err, expected)
_, err = r.ResolveSourceMetadata(t.Context(), &pb.SourceOp{}, sourceresolver.Opt{})
require.ErrorIs(t, err, expected)
require.EqualValues(t, 1, runs.Load())
require.ErrorIs(t, r.Close(), expected)
}
func TestResolverCloseIgnoresTerminalContextErrors(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
err error
}{
{name: "canceled", err: context.Canceled},
{name: "deadline", err: context.DeadlineExceeded},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r := newWithRun(func(ctx context.Context, ready chan<- sourceresolver.MetaResolver) error {
return tc.err
})
_, err := r.ResolveSourceMetadata(t.Context(), &pb.SourceOp{}, sourceresolver.Opt{})
require.ErrorIs(t, err, tc.err)
require.NoError(t, r.Close())
})
}
}
func TestResolverConcurrentResolveUsesSingleOpen(t *testing.T) {
t.Parallel()
var runs atomic.Int32
mr := &fakeMetaResolver{resp: &sourceresolver.MetaResponse{}}
r := newWithRun(func(ctx context.Context, ready chan<- sourceresolver.MetaResolver) error {
runs.Add(1)
ready <- mr
<-ctx.Done()
return context.Cause(ctx)
})
const n = 16
errCh := make(chan error, n)
var wg sync.WaitGroup
wg.Add(n)
for range n {
go func() {
defer wg.Done()
_, err := r.ResolveSourceMetadata(t.Context(), &pb.SourceOp{}, sourceresolver.Opt{})
errCh <- err
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
require.NoError(t, err)
}
require.EqualValues(t, 1, runs.Load())
require.EqualValues(t, n, mr.calls.Load())
done := make(chan struct{})
closeErr := make(chan error, 1)
go func() {
defer close(done)
closeErr <- r.Close()
}()
select {
case <-done:
require.NoError(t, <-closeErr)
case <-time.After(2 * time.Second):
t.Fatal("close timed out")
}
}
func TestResolverFirstCanceledContextDoesNotPoisonFutureCalls(t *testing.T) {
t.Parallel()
mr := &fakeMetaResolver{resp: &sourceresolver.MetaResponse{}}
started := make(chan struct{})
release := make(chan struct{})
r := newWithRun(func(ctx context.Context, ready chan<- sourceresolver.MetaResolver) error {
close(started)
<-release
ready <- mr
<-ctx.Done()
return context.Cause(ctx)
})
canceledCtx, cancel := context.WithCancelCause(t.Context())
cancel(context.Canceled)
_, err := r.ResolveSourceMetadata(canceledCtx, &pb.SourceOp{}, sourceresolver.Opt{})
require.ErrorIs(t, err, context.Canceled)
<-started
close(release)
_, err = r.ResolveSourceMetadata(t.Context(), &pb.SourceOp{}, sourceresolver.Opt{})
require.NoError(t, err)
require.NoError(t, r.Close())
}