github.com/tonistiigi/docker@v0.10.1-0.20240229224939-974013b0dc6a/builder/builder-next/builder.go (about)

     1  package buildkit
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"net"
     8  	"strconv"
     9  	"strings"
    10  	"sync"
    11  	"time"
    12  
    13  	"github.com/containerd/containerd/platforms"
    14  	"github.com/containerd/containerd/remotes/docker"
    15  	"github.com/docker/docker/api/types"
    16  	"github.com/docker/docker/api/types/backend"
    17  	timetypes "github.com/docker/docker/api/types/time"
    18  	"github.com/docker/docker/builder"
    19  	"github.com/docker/docker/builder/builder-next/exporter"
    20  	"github.com/docker/docker/builder/builder-next/exporter/mobyexporter"
    21  	"github.com/docker/docker/builder/builder-next/exporter/overrides"
    22  	"github.com/docker/docker/daemon/config"
    23  	"github.com/docker/docker/daemon/images"
    24  	"github.com/docker/docker/libnetwork"
    25  	"github.com/docker/docker/opts"
    26  	"github.com/docker/docker/pkg/idtools"
    27  	"github.com/docker/docker/pkg/streamformatter"
    28  	"github.com/docker/go-units"
    29  	controlapi "github.com/moby/buildkit/api/services/control"
    30  	"github.com/moby/buildkit/client"
    31  	"github.com/moby/buildkit/control"
    32  	"github.com/moby/buildkit/identity"
    33  	"github.com/moby/buildkit/session"
    34  	"github.com/moby/buildkit/util/entitlements"
    35  	"github.com/moby/buildkit/util/tracing"
    36  	"github.com/pkg/errors"
    37  	"golang.org/x/sync/errgroup"
    38  	"google.golang.org/grpc"
    39  	grpcmetadata "google.golang.org/grpc/metadata"
    40  )
    41  
    42  type errMultipleFilterValues struct{}
    43  
    44  func (errMultipleFilterValues) Error() string { return "filters expect only one value" }
    45  
    46  func (errMultipleFilterValues) InvalidParameter() {}
    47  
    48  type errConflictFilter struct {
    49  	a, b string
    50  }
    51  
    52  func (e errConflictFilter) Error() string {
    53  	return fmt.Sprintf("conflicting filters: %q and %q", e.a, e.b)
    54  }
    55  
    56  func (errConflictFilter) InvalidParameter() {}
    57  
    58  type errInvalidFilterValue struct {
    59  	error
    60  }
    61  
    62  func (errInvalidFilterValue) InvalidParameter() {}
    63  
    64  var cacheFields = map[string]bool{
    65  	"id":          true,
    66  	"parent":      true,
    67  	"type":        true,
    68  	"description": true,
    69  	"inuse":       true,
    70  	"shared":      true,
    71  	"private":     true,
    72  	// fields from buildkit that are not exposed
    73  	"mutable":   false,
    74  	"immutable": false,
    75  }
    76  
    77  // Opt is option struct required for creating the builder
    78  type Opt struct {
    79  	SessionManager      *session.Manager
    80  	Root                string
    81  	EngineID            string
    82  	Dist                images.DistributionServices
    83  	ImageTagger         mobyexporter.ImageTagger
    84  	NetworkController   *libnetwork.Controller
    85  	DefaultCgroupParent string
    86  	RegistryHosts       docker.RegistryHosts
    87  	BuilderConfig       config.BuilderConfig
    88  	Rootless            bool
    89  	IdentityMapping     idtools.IdentityMapping
    90  	DNSConfig           config.DNSConfig
    91  	ApparmorProfile     string
    92  	UseSnapshotter      bool
    93  	Snapshotter         string
    94  	ContainerdAddress   string
    95  	ContainerdNamespace string
    96  }
    97  
    98  // Builder can build using BuildKit backend
    99  type Builder struct {
   100  	controller     *control.Controller
   101  	dnsconfig      config.DNSConfig
   102  	reqBodyHandler *reqBodyHandler
   103  
   104  	mu             sync.Mutex
   105  	jobs           map[string]*buildJob
   106  	useSnapshotter bool
   107  }
   108  
   109  // New creates a new builder
   110  func New(ctx context.Context, opt Opt) (*Builder, error) {
   111  	reqHandler := newReqBodyHandler(tracing.DefaultTransport)
   112  
   113  	c, err := newController(ctx, reqHandler, opt)
   114  	if err != nil {
   115  		return nil, err
   116  	}
   117  	b := &Builder{
   118  		controller:     c,
   119  		dnsconfig:      opt.DNSConfig,
   120  		reqBodyHandler: reqHandler,
   121  		jobs:           map[string]*buildJob{},
   122  		useSnapshotter: opt.UseSnapshotter,
   123  	}
   124  	return b, nil
   125  }
   126  
   127  func (b *Builder) Close() error {
   128  	return b.controller.Close()
   129  }
   130  
   131  // RegisterGRPC registers controller to the grpc server.
   132  func (b *Builder) RegisterGRPC(s *grpc.Server) {
   133  	b.controller.Register(s)
   134  }
   135  
   136  // Cancel cancels a build using ID
   137  func (b *Builder) Cancel(ctx context.Context, id string) error {
   138  	b.mu.Lock()
   139  	if j, ok := b.jobs[id]; ok && j.cancel != nil {
   140  		j.cancel()
   141  	}
   142  	b.mu.Unlock()
   143  	return nil
   144  }
   145  
   146  // DiskUsage returns a report about space used by build cache
   147  func (b *Builder) DiskUsage(ctx context.Context) ([]*types.BuildCache, error) {
   148  	duResp, err := b.controller.DiskUsage(ctx, &controlapi.DiskUsageRequest{})
   149  	if err != nil {
   150  		return nil, err
   151  	}
   152  
   153  	var items []*types.BuildCache
   154  	for _, r := range duResp.Record {
   155  		items = append(items, &types.BuildCache{
   156  			ID:          r.ID,
   157  			Parent:      r.Parent, //nolint:staticcheck // ignore SA1019 (Parent field is deprecated)
   158  			Parents:     r.Parents,
   159  			Type:        r.RecordType,
   160  			Description: r.Description,
   161  			InUse:       r.InUse,
   162  			Shared:      r.Shared,
   163  			Size:        r.Size_,
   164  			CreatedAt:   r.CreatedAt,
   165  			LastUsedAt:  r.LastUsedAt,
   166  			UsageCount:  int(r.UsageCount),
   167  		})
   168  	}
   169  	return items, nil
   170  }
   171  
   172  // Prune clears all reclaimable build cache
   173  func (b *Builder) Prune(ctx context.Context, opts types.BuildCachePruneOptions) (int64, []string, error) {
   174  	ch := make(chan *controlapi.UsageRecord)
   175  
   176  	eg, ctx := errgroup.WithContext(ctx)
   177  
   178  	validFilters := make(map[string]bool, 1+len(cacheFields))
   179  	validFilters["unused-for"] = true
   180  	validFilters["until"] = true
   181  	validFilters["label"] = true  // TODO(tiborvass): handle label
   182  	validFilters["label!"] = true // TODO(tiborvass): handle label!
   183  	for k, v := range cacheFields {
   184  		validFilters[k] = v
   185  	}
   186  	if err := opts.Filters.Validate(validFilters); err != nil {
   187  		return 0, nil, err
   188  	}
   189  
   190  	pi, err := toBuildkitPruneInfo(opts)
   191  	if err != nil {
   192  		return 0, nil, err
   193  	}
   194  
   195  	eg.Go(func() error {
   196  		defer close(ch)
   197  		return b.controller.Prune(&controlapi.PruneRequest{
   198  			All:          pi.All,
   199  			KeepDuration: int64(pi.KeepDuration),
   200  			KeepBytes:    pi.KeepBytes,
   201  			Filter:       pi.Filter,
   202  		}, &pruneProxy{
   203  			streamProxy: streamProxy{ctx: ctx},
   204  			ch:          ch,
   205  		})
   206  	})
   207  
   208  	var size int64
   209  	var cacheIDs []string
   210  	eg.Go(func() error {
   211  		for r := range ch {
   212  			size += r.Size_
   213  			cacheIDs = append(cacheIDs, r.ID)
   214  		}
   215  		return nil
   216  	})
   217  
   218  	if err := eg.Wait(); err != nil {
   219  		return 0, nil, err
   220  	}
   221  
   222  	return size, cacheIDs, nil
   223  }
   224  
   225  // Build executes a build request
   226  func (b *Builder) Build(ctx context.Context, opt backend.BuildConfig) (*builder.Result, error) {
   227  	if len(opt.Options.Outputs) > 1 {
   228  		return nil, errors.Errorf("multiple outputs not supported")
   229  	}
   230  
   231  	rc := opt.Source
   232  	if buildID := opt.Options.BuildID; buildID != "" {
   233  		b.mu.Lock()
   234  
   235  		upload := false
   236  		if strings.HasPrefix(buildID, "upload-request:") {
   237  			upload = true
   238  			buildID = strings.TrimPrefix(buildID, "upload-request:")
   239  		}
   240  
   241  		if _, ok := b.jobs[buildID]; !ok {
   242  			b.jobs[buildID] = newBuildJob()
   243  		}
   244  		j := b.jobs[buildID]
   245  		var cancel func()
   246  		ctx, cancel = context.WithCancel(ctx)
   247  		j.cancel = cancel
   248  		b.mu.Unlock()
   249  
   250  		if upload {
   251  			ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
   252  			defer cancel()
   253  			err := j.SetUpload(ctx2, rc)
   254  			return nil, err
   255  		}
   256  
   257  		if remoteContext := opt.Options.RemoteContext; remoteContext == "upload-request" {
   258  			ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
   259  			defer cancel()
   260  			var err error
   261  			rc, err = j.WaitUpload(ctx2)
   262  			if err != nil {
   263  				return nil, err
   264  			}
   265  			opt.Options.RemoteContext = ""
   266  		}
   267  
   268  		defer func() {
   269  			b.mu.Lock()
   270  			delete(b.jobs, buildID)
   271  			b.mu.Unlock()
   272  		}()
   273  	}
   274  
   275  	var out builder.Result
   276  
   277  	id := identity.NewID()
   278  
   279  	frontendAttrs := map[string]string{}
   280  
   281  	if opt.Options.Target != "" {
   282  		frontendAttrs["target"] = opt.Options.Target
   283  	}
   284  
   285  	if opt.Options.Dockerfile != "" && opt.Options.Dockerfile != "." {
   286  		frontendAttrs["filename"] = opt.Options.Dockerfile
   287  	}
   288  
   289  	if opt.Options.RemoteContext != "" {
   290  		if opt.Options.RemoteContext != "client-session" {
   291  			frontendAttrs["context"] = opt.Options.RemoteContext
   292  		}
   293  	} else {
   294  		url, cancel := b.reqBodyHandler.newRequest(rc)
   295  		defer cancel()
   296  		frontendAttrs["context"] = url
   297  	}
   298  
   299  	cacheFrom := append([]string{}, opt.Options.CacheFrom...)
   300  
   301  	frontendAttrs["cache-from"] = strings.Join(cacheFrom, ",")
   302  
   303  	for k, v := range opt.Options.BuildArgs {
   304  		if v == nil {
   305  			continue
   306  		}
   307  		frontendAttrs["build-arg:"+k] = *v
   308  	}
   309  
   310  	for k, v := range opt.Options.Labels {
   311  		frontendAttrs["label:"+k] = v
   312  	}
   313  
   314  	if opt.Options.NoCache {
   315  		frontendAttrs["no-cache"] = ""
   316  	}
   317  
   318  	if opt.Options.PullParent {
   319  		frontendAttrs["image-resolve-mode"] = "pull"
   320  	} else {
   321  		frontendAttrs["image-resolve-mode"] = "default"
   322  	}
   323  
   324  	if opt.Options.Platform != "" {
   325  		// same as in newBuilder in builder/dockerfile.builder.go
   326  		// TODO: remove once opt.Options.Platform is of type specs.Platform
   327  		_, err := platforms.Parse(opt.Options.Platform)
   328  		if err != nil {
   329  			return nil, err
   330  		}
   331  		frontendAttrs["platform"] = opt.Options.Platform
   332  	}
   333  
   334  	switch opt.Options.NetworkMode {
   335  	case "host", "none":
   336  		frontendAttrs["force-network-mode"] = opt.Options.NetworkMode
   337  	case "", "default":
   338  	default:
   339  		return nil, errors.Errorf("network mode %q not supported by buildkit", opt.Options.NetworkMode)
   340  	}
   341  
   342  	extraHosts, err := toBuildkitExtraHosts(opt.Options.ExtraHosts, b.dnsconfig.HostGatewayIP)
   343  	if err != nil {
   344  		return nil, err
   345  	}
   346  	frontendAttrs["add-hosts"] = extraHosts
   347  
   348  	if opt.Options.ShmSize > 0 {
   349  		frontendAttrs["shm-size"] = strconv.FormatInt(opt.Options.ShmSize, 10)
   350  	}
   351  
   352  	ulimits, err := toBuildkitUlimits(opt.Options.Ulimits)
   353  	if err != nil {
   354  		return nil, err
   355  	} else if len(ulimits) > 0 {
   356  		frontendAttrs["ulimit"] = ulimits
   357  	}
   358  
   359  	exporterName := ""
   360  	exporterAttrs := map[string]string{}
   361  	if len(opt.Options.Outputs) == 0 {
   362  		exporterName = exporter.Moby
   363  	} else {
   364  		// cacheonly is a special type for triggering skipping all exporters
   365  		if opt.Options.Outputs[0].Type != "cacheonly" {
   366  			exporterName = opt.Options.Outputs[0].Type
   367  			exporterAttrs = opt.Options.Outputs[0].Attrs
   368  		}
   369  	}
   370  
   371  	if (exporterName == client.ExporterImage || exporterName == exporter.Moby) && len(opt.Options.Tags) > 0 {
   372  		nameAttr, err := overrides.SanitizeRepoAndTags(opt.Options.Tags)
   373  		if err != nil {
   374  			return nil, err
   375  		}
   376  		if exporterAttrs == nil {
   377  			exporterAttrs = make(map[string]string)
   378  		}
   379  		exporterAttrs["name"] = strings.Join(nameAttr, ",")
   380  	}
   381  
   382  	cache := controlapi.CacheOptions{}
   383  	if inlineCache := opt.Options.BuildArgs["BUILDKIT_INLINE_CACHE"]; inlineCache != nil {
   384  		if b, err := strconv.ParseBool(*inlineCache); err == nil && b {
   385  			cache.Exports = append(cache.Exports, &controlapi.CacheOptionsEntry{
   386  				Type: "inline",
   387  			})
   388  		}
   389  	}
   390  
   391  	req := &controlapi.SolveRequest{
   392  		Ref: id,
   393  		Exporters: []*controlapi.Exporter{
   394  			&controlapi.Exporter{Type: exporterName, Attrs: exporterAttrs},
   395  		},
   396  		Frontend:      "dockerfile.v0",
   397  		FrontendAttrs: frontendAttrs,
   398  		Session:       opt.Options.SessionID,
   399  		Cache:         cache,
   400  	}
   401  
   402  	if opt.Options.NetworkMode == "host" {
   403  		req.Entitlements = append(req.Entitlements, entitlements.EntitlementNetworkHost)
   404  	}
   405  
   406  	aux := streamformatter.AuxFormatter{Writer: opt.ProgressWriter.Output}
   407  
   408  	eg, ctx := errgroup.WithContext(ctx)
   409  
   410  	eg.Go(func() error {
   411  		resp, err := b.controller.Solve(ctx, req)
   412  		if err != nil {
   413  			return err
   414  		}
   415  		if exporterName != exporter.Moby && exporterName != client.ExporterImage {
   416  			return nil
   417  		}
   418  		id, ok := resp.ExporterResponse["containerimage.digest"]
   419  		if !ok {
   420  			return errors.Errorf("missing image id")
   421  		}
   422  		out.ImageID = id
   423  		return aux.Emit("moby.image.id", types.BuildResult{ID: id})
   424  	})
   425  
   426  	ch := make(chan *controlapi.StatusResponse)
   427  
   428  	eg.Go(func() error {
   429  		defer close(ch)
   430  		// streamProxy.ctx is not set to ctx because when request is cancelled,
   431  		// only the build request has to be cancelled, not the status request.
   432  		stream := &statusProxy{streamProxy: streamProxy{ctx: context.TODO()}, ch: ch}
   433  		return b.controller.Status(&controlapi.StatusRequest{Ref: id}, stream)
   434  	})
   435  
   436  	eg.Go(func() error {
   437  		for sr := range ch {
   438  			dt, err := sr.Marshal()
   439  			if err != nil {
   440  				return err
   441  			}
   442  			if err := aux.Emit("moby.buildkit.trace", dt); err != nil {
   443  				return err
   444  			}
   445  		}
   446  		return nil
   447  	})
   448  
   449  	if err := eg.Wait(); err != nil {
   450  		return nil, err
   451  	}
   452  
   453  	return &out, nil
   454  }
   455  
   456  type streamProxy struct {
   457  	ctx context.Context
   458  }
   459  
   460  func (sp *streamProxy) SetHeader(_ grpcmetadata.MD) error {
   461  	return nil
   462  }
   463  
   464  func (sp *streamProxy) SendHeader(_ grpcmetadata.MD) error {
   465  	return nil
   466  }
   467  
   468  func (sp *streamProxy) SetTrailer(_ grpcmetadata.MD) {
   469  }
   470  
   471  func (sp *streamProxy) Context() context.Context {
   472  	return sp.ctx
   473  }
   474  
   475  func (sp *streamProxy) RecvMsg(m interface{}) error {
   476  	return io.EOF
   477  }
   478  
   479  type statusProxy struct {
   480  	streamProxy
   481  	ch chan *controlapi.StatusResponse
   482  }
   483  
   484  func (sp *statusProxy) Send(resp *controlapi.StatusResponse) error {
   485  	return sp.SendMsg(resp)
   486  }
   487  
   488  func (sp *statusProxy) SendMsg(m interface{}) error {
   489  	if sr, ok := m.(*controlapi.StatusResponse); ok {
   490  		sp.ch <- sr
   491  	}
   492  	return nil
   493  }
   494  
   495  type pruneProxy struct {
   496  	streamProxy
   497  	ch chan *controlapi.UsageRecord
   498  }
   499  
   500  func (sp *pruneProxy) Send(resp *controlapi.UsageRecord) error {
   501  	return sp.SendMsg(resp)
   502  }
   503  
   504  func (sp *pruneProxy) SendMsg(m interface{}) error {
   505  	if sr, ok := m.(*controlapi.UsageRecord); ok {
   506  		sp.ch <- sr
   507  	}
   508  	return nil
   509  }
   510  
   511  type wrapRC struct {
   512  	io.ReadCloser
   513  	once   sync.Once
   514  	err    error
   515  	waitCh chan struct{}
   516  }
   517  
   518  func (w *wrapRC) Read(b []byte) (int, error) {
   519  	n, err := w.ReadCloser.Read(b)
   520  	if err != nil {
   521  		e := err
   522  		if e == io.EOF {
   523  			e = nil
   524  		}
   525  		w.close(e)
   526  	}
   527  	return n, err
   528  }
   529  
   530  func (w *wrapRC) Close() error {
   531  	err := w.ReadCloser.Close()
   532  	w.close(err)
   533  	return err
   534  }
   535  
   536  func (w *wrapRC) close(err error) {
   537  	w.once.Do(func() {
   538  		w.err = err
   539  		close(w.waitCh)
   540  	})
   541  }
   542  
   543  func (w *wrapRC) wait() error {
   544  	<-w.waitCh
   545  	return w.err
   546  }
   547  
   548  type buildJob struct {
   549  	cancel func()
   550  	waitCh chan func(io.ReadCloser) error
   551  }
   552  
   553  func newBuildJob() *buildJob {
   554  	return &buildJob{waitCh: make(chan func(io.ReadCloser) error)}
   555  }
   556  
   557  func (j *buildJob) WaitUpload(ctx context.Context) (io.ReadCloser, error) {
   558  	done := make(chan struct{})
   559  
   560  	var upload io.ReadCloser
   561  	fn := func(rc io.ReadCloser) error {
   562  		w := &wrapRC{ReadCloser: rc, waitCh: make(chan struct{})}
   563  		upload = w
   564  		close(done)
   565  		return w.wait()
   566  	}
   567  
   568  	select {
   569  	case <-ctx.Done():
   570  		return nil, ctx.Err()
   571  	case j.waitCh <- fn:
   572  		<-done
   573  		return upload, nil
   574  	}
   575  }
   576  
   577  func (j *buildJob) SetUpload(ctx context.Context, rc io.ReadCloser) error {
   578  	select {
   579  	case <-ctx.Done():
   580  		return ctx.Err()
   581  	case fn := <-j.waitCh:
   582  		return fn(rc)
   583  	}
   584  }
   585  
   586  // toBuildkitExtraHosts converts hosts from docker key:value format to buildkit's csv format
   587  func toBuildkitExtraHosts(inp []string, hostGatewayIP net.IP) (string, error) {
   588  	if len(inp) == 0 {
   589  		return "", nil
   590  	}
   591  	hosts := make([]string, 0, len(inp))
   592  	for _, h := range inp {
   593  		host, ip, ok := strings.Cut(h, ":")
   594  		if !ok || host == "" || ip == "" {
   595  			return "", errors.Errorf("invalid host %s", h)
   596  		}
   597  		// If the IP Address is a "host-gateway", replace this value with the
   598  		// IP address stored in the daemon level HostGatewayIP config variable.
   599  		if ip == opts.HostGatewayName {
   600  			gateway := hostGatewayIP.String()
   601  			if gateway == "" {
   602  				return "", fmt.Errorf("unable to derive the IP value for host-gateway")
   603  			}
   604  			ip = gateway
   605  		} else if net.ParseIP(ip) == nil {
   606  			return "", fmt.Errorf("invalid host %s", h)
   607  		}
   608  		hosts = append(hosts, host+"="+ip)
   609  	}
   610  	return strings.Join(hosts, ","), nil
   611  }
   612  
   613  // toBuildkitUlimits converts ulimits from docker type=soft:hard format to buildkit's csv format
   614  func toBuildkitUlimits(inp []*units.Ulimit) (string, error) {
   615  	if len(inp) == 0 {
   616  		return "", nil
   617  	}
   618  	ulimits := make([]string, 0, len(inp))
   619  	for _, ulimit := range inp {
   620  		ulimits = append(ulimits, ulimit.String())
   621  	}
   622  	return strings.Join(ulimits, ","), nil
   623  }
   624  
   625  func toBuildkitPruneInfo(opts types.BuildCachePruneOptions) (client.PruneInfo, error) {
   626  	var until time.Duration
   627  	untilValues := opts.Filters.Get("until")          // canonical
   628  	unusedForValues := opts.Filters.Get("unused-for") // deprecated synonym for "until" filter
   629  
   630  	if len(untilValues) > 0 && len(unusedForValues) > 0 {
   631  		return client.PruneInfo{}, errConflictFilter{"until", "unused-for"}
   632  	}
   633  	filterKey := "until"
   634  	if len(unusedForValues) > 0 {
   635  		filterKey = "unused-for"
   636  	}
   637  	untilValues = append(untilValues, unusedForValues...)
   638  
   639  	switch len(untilValues) {
   640  	case 0:
   641  		// nothing to do
   642  	case 1:
   643  		ts, err := timetypes.GetTimestamp(untilValues[0], time.Now())
   644  		if err != nil {
   645  			return client.PruneInfo{}, errInvalidFilterValue{
   646  				errors.Wrapf(err, "%q filter expects a duration (e.g., '24h') or a timestamp", filterKey),
   647  			}
   648  		}
   649  		seconds, nanoseconds, err := timetypes.ParseTimestamps(ts, 0)
   650  		if err != nil {
   651  			return client.PruneInfo{}, errInvalidFilterValue{
   652  				errors.Wrapf(err, "failed to parse timestamp %q", ts),
   653  			}
   654  		}
   655  
   656  		until = time.Since(time.Unix(seconds, nanoseconds))
   657  	default:
   658  		return client.PruneInfo{}, errMultipleFilterValues{}
   659  	}
   660  
   661  	bkFilter := make([]string, 0, opts.Filters.Len())
   662  	for cacheField := range cacheFields {
   663  		if opts.Filters.Contains(cacheField) {
   664  			values := opts.Filters.Get(cacheField)
   665  			switch len(values) {
   666  			case 0:
   667  				bkFilter = append(bkFilter, cacheField)
   668  			case 1:
   669  				if cacheField == "id" {
   670  					bkFilter = append(bkFilter, cacheField+"~="+values[0])
   671  				} else {
   672  					bkFilter = append(bkFilter, cacheField+"=="+values[0])
   673  				}
   674  			default:
   675  				return client.PruneInfo{}, errMultipleFilterValues{}
   676  			}
   677  		}
   678  	}
   679  	return client.PruneInfo{
   680  		All:          opts.All,
   681  		KeepDuration: until,
   682  		KeepBytes:    opts.KeepStorage,
   683  		Filter:       []string{strings.Join(bkFilter, ",")},
   684  	}, nil
   685  }