github.com/xeptore/docker-cli@v20.10.14+incompatible/cli/command/image/build_buildkit.go (about)

     1  package image
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/csv"
     7  	"encoding/json"
     8  	"fmt"
     9  	"io"
    10  	"io/ioutil"
    11  	"net"
    12  	"os"
    13  	"path/filepath"
    14  	"strings"
    15  
    16  	"github.com/containerd/console"
    17  	"github.com/containerd/containerd/platforms"
    18  	"github.com/docker/cli/cli"
    19  	"github.com/docker/cli/cli/command"
    20  	"github.com/docker/cli/cli/command/image/build"
    21  	"github.com/docker/cli/opts"
    22  	"github.com/docker/docker/api/types"
    23  	"github.com/docker/docker/pkg/jsonmessage"
    24  	"github.com/docker/docker/pkg/stringid"
    25  	"github.com/docker/docker/pkg/urlutil"
    26  	controlapi "github.com/moby/buildkit/api/services/control"
    27  	"github.com/moby/buildkit/client"
    28  	"github.com/moby/buildkit/session"
    29  	"github.com/moby/buildkit/session/auth/authprovider"
    30  	"github.com/moby/buildkit/session/filesync"
    31  	"github.com/moby/buildkit/session/secrets/secretsprovider"
    32  	"github.com/moby/buildkit/session/sshforward/sshprovider"
    33  	"github.com/moby/buildkit/util/appcontext"
    34  	"github.com/moby/buildkit/util/progress/progressui"
    35  	"github.com/moby/buildkit/util/progress/progresswriter"
    36  	"github.com/pkg/errors"
    37  	fsutiltypes "github.com/tonistiigi/fsutil/types"
    38  	"github.com/tonistiigi/go-rosetta"
    39  	"golang.org/x/sync/errgroup"
    40  )
    41  
    42  const uploadRequestRemote = "upload-request"
    43  
    44  var errDockerfileConflict = errors.New("ambiguous Dockerfile source: both stdin and flag correspond to Dockerfiles")
    45  
    46  //nolint: gocyclo
    47  func runBuildBuildKit(dockerCli command.Cli, options buildOptions) error {
    48  	ctx := appcontext.Context()
    49  
    50  	s, err := trySession(dockerCli, options.context, false)
    51  	if err != nil {
    52  		return err
    53  	}
    54  	if s == nil {
    55  		return errors.Errorf("buildkit not supported by daemon")
    56  	}
    57  
    58  	if options.imageIDFile != "" {
    59  		// Avoid leaving a stale file if we eventually fail
    60  		if err := os.Remove(options.imageIDFile); err != nil && !os.IsNotExist(err) {
    61  			return errors.Wrap(err, "removing image ID file")
    62  		}
    63  	}
    64  
    65  	var (
    66  		remote           string
    67  		body             io.Reader
    68  		dockerfileName   = options.dockerfileName
    69  		dockerfileReader io.ReadCloser
    70  		dockerfileDir    string
    71  		contextDir       string
    72  	)
    73  
    74  	stdoutUsed := false
    75  
    76  	switch {
    77  	case options.contextFromStdin():
    78  		if options.dockerfileFromStdin() {
    79  			return errStdinConflict
    80  		}
    81  		rc, isArchive, err := build.DetectArchiveReader(dockerCli.In())
    82  		if err != nil {
    83  			return err
    84  		}
    85  		if isArchive {
    86  			body = rc
    87  			remote = uploadRequestRemote
    88  		} else {
    89  			if options.dockerfileName != "" {
    90  				return errDockerfileConflict
    91  			}
    92  			dockerfileReader = rc
    93  			remote = clientSessionRemote
    94  			// TODO: make fssync handle empty contextdir
    95  			contextDir, _ = ioutil.TempDir("", "empty-dir")
    96  			defer os.RemoveAll(contextDir)
    97  		}
    98  	case isLocalDir(options.context):
    99  		contextDir = options.context
   100  		if options.dockerfileFromStdin() {
   101  			dockerfileReader = dockerCli.In()
   102  		} else if options.dockerfileName != "" {
   103  			dockerfileName = filepath.Base(options.dockerfileName)
   104  			dockerfileDir = filepath.Dir(options.dockerfileName)
   105  		} else {
   106  			dockerfileDir = options.context
   107  		}
   108  		remote = clientSessionRemote
   109  	case urlutil.IsGitURL(options.context):
   110  		remote = options.context
   111  	case urlutil.IsURL(options.context):
   112  		remote = options.context
   113  	default:
   114  		return errors.Errorf("unable to prepare context: path %q not found", options.context)
   115  	}
   116  
   117  	if dockerfileReader != nil {
   118  		dockerfileName = build.DefaultDockerfileName
   119  		dockerfileDir, err = build.WriteTempDockerfile(dockerfileReader)
   120  		if err != nil {
   121  			return err
   122  		}
   123  		defer os.RemoveAll(dockerfileDir)
   124  	}
   125  
   126  	outputs, err := parseOutputs(options.outputs)
   127  	if err != nil {
   128  		return errors.Wrapf(err, "failed to parse outputs")
   129  	}
   130  
   131  	for _, out := range outputs {
   132  		switch out.Type {
   133  		case "local":
   134  			// dest is handled on client side for local exporter
   135  			outDir, ok := out.Attrs["dest"]
   136  			if !ok {
   137  				return errors.Errorf("dest is required for local output")
   138  			}
   139  			delete(out.Attrs, "dest")
   140  			s.Allow(filesync.NewFSSyncTargetDir(outDir))
   141  		case "tar":
   142  			// dest is handled on client side for tar exporter
   143  			outFile, ok := out.Attrs["dest"]
   144  			if !ok {
   145  				return errors.Errorf("dest is required for tar output")
   146  			}
   147  			var w io.WriteCloser
   148  			if outFile == "-" {
   149  				if _, err := console.ConsoleFromFile(os.Stdout); err == nil {
   150  					return errors.Errorf("refusing to write output to console")
   151  				}
   152  				w = os.Stdout
   153  				stdoutUsed = true
   154  			} else {
   155  				f, err := os.Create(outFile)
   156  				if err != nil {
   157  					return errors.Wrapf(err, "failed to open %s", outFile)
   158  				}
   159  				w = f
   160  			}
   161  			output := func(map[string]string) (io.WriteCloser, error) { return w, nil }
   162  			s.Allow(filesync.NewFSSyncTarget(output))
   163  		}
   164  	}
   165  
   166  	if dockerfileDir != "" {
   167  		s.Allow(filesync.NewFSSyncProvider([]filesync.SyncedDir{
   168  			{
   169  				Name: "context",
   170  				Dir:  contextDir,
   171  				Map:  resetUIDAndGID,
   172  			},
   173  			{
   174  				Name: "dockerfile",
   175  				Dir:  dockerfileDir,
   176  			},
   177  		}))
   178  	}
   179  
   180  	dockerAuthProvider := authprovider.NewDockerAuthProvider(os.Stderr)
   181  	s.Allow(dockerAuthProvider)
   182  	if len(options.secrets) > 0 {
   183  		sp, err := parseSecretSpecs(options.secrets)
   184  		if err != nil {
   185  			return errors.Wrapf(err, "could not parse secrets: %v", options.secrets)
   186  		}
   187  		s.Allow(sp)
   188  	}
   189  	if len(options.ssh) > 0 {
   190  		sshp, err := parseSSHSpecs(options.ssh)
   191  		if err != nil {
   192  			return errors.Wrapf(err, "could not parse ssh: %v", options.ssh)
   193  		}
   194  		s.Allow(sshp)
   195  	}
   196  
   197  	eg, ctx := errgroup.WithContext(ctx)
   198  
   199  	dialSession := func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) {
   200  		return dockerCli.Client().DialHijack(ctx, "/session", proto, meta)
   201  	}
   202  	eg.Go(func() error {
   203  		return s.Run(context.TODO(), dialSession)
   204  	})
   205  
   206  	buildID := stringid.GenerateRandomID()
   207  	if body != nil {
   208  		eg.Go(func() error {
   209  			buildOptions := types.ImageBuildOptions{
   210  				Version: types.BuilderBuildKit,
   211  				BuildID: uploadRequestRemote + ":" + buildID,
   212  			}
   213  
   214  			response, err := dockerCli.Client().ImageBuild(context.Background(), body, buildOptions)
   215  			if err != nil {
   216  				return err
   217  			}
   218  			defer response.Body.Close()
   219  			return nil
   220  		})
   221  	}
   222  
   223  	if v := os.Getenv("BUILDKIT_PROGRESS"); v != "" && options.progress == "auto" {
   224  		options.progress = v
   225  	}
   226  
   227  	if strings.EqualFold(options.platform, "local") {
   228  		p := platforms.DefaultSpec()
   229  		p.Architecture = rosetta.NativeArch() // current binary architecture might be emulated
   230  		options.platform = platforms.Format(p)
   231  	}
   232  
   233  	eg.Go(func() error {
   234  		defer func() { // make sure the Status ends cleanly on build errors
   235  			s.Close()
   236  		}()
   237  
   238  		buildOptions := imageBuildOptions(dockerCli, options)
   239  		buildOptions.Version = types.BuilderBuildKit
   240  		buildOptions.Dockerfile = dockerfileName
   241  		// buildOptions.AuthConfigs = authConfigs   // handled by session
   242  		buildOptions.RemoteContext = remote
   243  		buildOptions.SessionID = s.ID()
   244  		buildOptions.BuildID = buildID
   245  		buildOptions.Outputs = outputs
   246  		return doBuild(ctx, eg, dockerCli, stdoutUsed, options, buildOptions, dockerAuthProvider)
   247  	})
   248  
   249  	return eg.Wait()
   250  }
   251  
   252  //nolint: gocyclo
   253  func doBuild(ctx context.Context, eg *errgroup.Group, dockerCli command.Cli, stdoutUsed bool, options buildOptions, buildOptions types.ImageBuildOptions, at session.Attachable) (finalErr error) {
   254  	response, err := dockerCli.Client().ImageBuild(context.Background(), nil, buildOptions)
   255  	if err != nil {
   256  		return err
   257  	}
   258  	defer response.Body.Close()
   259  
   260  	done := make(chan struct{})
   261  	defer close(done)
   262  	eg.Go(func() error {
   263  		select {
   264  		case <-ctx.Done():
   265  			return dockerCli.Client().BuildCancel(context.TODO(), buildOptions.BuildID)
   266  		case <-done:
   267  		}
   268  		return nil
   269  	})
   270  
   271  	t := newTracer()
   272  	ssArr := []*client.SolveStatus{}
   273  
   274  	if err := opts.ValidateProgressOutput(options.progress); err != nil {
   275  		return err
   276  	}
   277  
   278  	displayStatus := func(out *os.File, displayCh chan *client.SolveStatus) {
   279  		var c console.Console
   280  		// TODO: Handle tty output in non-tty environment.
   281  		if cons, err := console.ConsoleFromFile(out); err == nil && (options.progress == "auto" || options.progress == "tty") {
   282  			c = cons
   283  		}
   284  		// not using shared context to not disrupt display but let it finish reporting errors
   285  		eg.Go(func() error {
   286  			return progressui.DisplaySolveStatus(context.TODO(), "", c, out, displayCh)
   287  		})
   288  		if s, ok := at.(interface {
   289  			SetLogger(progresswriter.Logger)
   290  		}); ok {
   291  			s.SetLogger(func(s *client.SolveStatus) {
   292  				displayCh <- s
   293  			})
   294  		}
   295  	}
   296  
   297  	if options.quiet {
   298  		eg.Go(func() error {
   299  			// TODO: make sure t.displayCh closes
   300  			for ss := range t.displayCh {
   301  				ssArr = append(ssArr, ss)
   302  			}
   303  			<-done
   304  			// TODO: verify that finalErr is indeed set when error occurs
   305  			if finalErr != nil {
   306  				displayCh := make(chan *client.SolveStatus)
   307  				go func() {
   308  					for _, ss := range ssArr {
   309  						displayCh <- ss
   310  					}
   311  					close(displayCh)
   312  				}()
   313  				displayStatus(os.Stderr, displayCh)
   314  			}
   315  			return nil
   316  		})
   317  	} else {
   318  		displayStatus(os.Stderr, t.displayCh)
   319  	}
   320  	defer close(t.displayCh)
   321  
   322  	buf := bytes.NewBuffer(nil)
   323  
   324  	imageID := ""
   325  	writeAux := func(msg jsonmessage.JSONMessage) {
   326  		if msg.ID == "moby.image.id" {
   327  			var result types.BuildResult
   328  			if err := json.Unmarshal(*msg.Aux, &result); err != nil {
   329  				fmt.Fprintf(dockerCli.Err(), "failed to parse aux message: %v", err)
   330  			}
   331  			imageID = result.ID
   332  			return
   333  		}
   334  		t.write(msg)
   335  	}
   336  
   337  	err = jsonmessage.DisplayJSONMessagesStream(response.Body, buf, dockerCli.Out().FD(), dockerCli.Out().IsTerminal(), writeAux)
   338  	if err != nil {
   339  		if jerr, ok := err.(*jsonmessage.JSONError); ok {
   340  			// If no error code is set, default to 1
   341  			if jerr.Code == 0 {
   342  				jerr.Code = 1
   343  			}
   344  			return cli.StatusError{Status: jerr.Message, StatusCode: jerr.Code}
   345  		}
   346  	}
   347  
   348  	// Everything worked so if -q was provided the output from the daemon
   349  	// should be just the image ID and we'll print that to stdout.
   350  	//
   351  	// TODO: we may want to use Aux messages with ID "moby.image.id" regardless of options.quiet (i.e. don't send HTTP param q=1)
   352  	// instead of assuming that output is image ID if options.quiet.
   353  	if options.quiet && !stdoutUsed {
   354  		imageID = buf.String()
   355  		fmt.Fprint(dockerCli.Out(), imageID)
   356  	}
   357  
   358  	if options.imageIDFile != "" {
   359  		if imageID == "" {
   360  			return errors.Errorf("cannot write %s because server did not provide an image ID", options.imageIDFile)
   361  		}
   362  		imageID = strings.TrimSpace(imageID)
   363  		if err := ioutil.WriteFile(options.imageIDFile, []byte(imageID), 0666); err != nil {
   364  			return errors.Wrap(err, "cannot write image ID file")
   365  		}
   366  	}
   367  	return err
   368  }
   369  
   370  func resetUIDAndGID(_ string, s *fsutiltypes.Stat) bool {
   371  	s.Uid = 0
   372  	s.Gid = 0
   373  	return true
   374  }
   375  
   376  type tracer struct {
   377  	displayCh chan *client.SolveStatus
   378  }
   379  
   380  func newTracer() *tracer {
   381  	return &tracer{
   382  		displayCh: make(chan *client.SolveStatus),
   383  	}
   384  }
   385  
   386  func (t *tracer) write(msg jsonmessage.JSONMessage) {
   387  	var resp controlapi.StatusResponse
   388  
   389  	if msg.ID != "moby.buildkit.trace" {
   390  		return
   391  	}
   392  
   393  	var dt []byte
   394  	// ignoring all messages that are not understood
   395  	if err := json.Unmarshal(*msg.Aux, &dt); err != nil {
   396  		return
   397  	}
   398  	if err := (&resp).Unmarshal(dt); err != nil {
   399  		return
   400  	}
   401  
   402  	s := client.SolveStatus{}
   403  	for _, v := range resp.Vertexes {
   404  		s.Vertexes = append(s.Vertexes, &client.Vertex{
   405  			Digest:    v.Digest,
   406  			Inputs:    v.Inputs,
   407  			Name:      v.Name,
   408  			Started:   v.Started,
   409  			Completed: v.Completed,
   410  			Error:     v.Error,
   411  			Cached:    v.Cached,
   412  		})
   413  	}
   414  	for _, v := range resp.Statuses {
   415  		s.Statuses = append(s.Statuses, &client.VertexStatus{
   416  			ID:        v.ID,
   417  			Vertex:    v.Vertex,
   418  			Name:      v.Name,
   419  			Total:     v.Total,
   420  			Current:   v.Current,
   421  			Timestamp: v.Timestamp,
   422  			Started:   v.Started,
   423  			Completed: v.Completed,
   424  		})
   425  	}
   426  	for _, v := range resp.Logs {
   427  		s.Logs = append(s.Logs, &client.VertexLog{
   428  			Vertex:    v.Vertex,
   429  			Stream:    int(v.Stream),
   430  			Data:      v.Msg,
   431  			Timestamp: v.Timestamp,
   432  		})
   433  	}
   434  
   435  	t.displayCh <- &s
   436  }
   437  
   438  func parseSecretSpecs(sl []string) (session.Attachable, error) {
   439  	fs := make([]secretsprovider.Source, 0, len(sl))
   440  	for _, v := range sl {
   441  		s, err := parseSecret(v)
   442  		if err != nil {
   443  			return nil, err
   444  		}
   445  		fs = append(fs, *s)
   446  	}
   447  	store, err := secretsprovider.NewStore(fs)
   448  	if err != nil {
   449  		return nil, err
   450  	}
   451  	return secretsprovider.NewSecretProvider(store), nil
   452  }
   453  
   454  func parseSecret(value string) (*secretsprovider.Source, error) {
   455  	csvReader := csv.NewReader(strings.NewReader(value))
   456  	fields, err := csvReader.Read()
   457  	if err != nil {
   458  		return nil, errors.Wrap(err, "failed to parse csv secret")
   459  	}
   460  
   461  	fs := secretsprovider.Source{}
   462  
   463  	var typ string
   464  	for _, field := range fields {
   465  		parts := strings.SplitN(field, "=", 2)
   466  		key := strings.ToLower(parts[0])
   467  
   468  		if len(parts) != 2 {
   469  			return nil, errors.Errorf("invalid field '%s' must be a key=value pair", field)
   470  		}
   471  
   472  		value := parts[1]
   473  		switch key {
   474  		case "type":
   475  			if value != "file" && value != "env" {
   476  				return nil, errors.Errorf("unsupported secret type %q", value)
   477  			}
   478  			typ = value
   479  		case "id":
   480  			fs.ID = value
   481  		case "source", "src":
   482  			fs.FilePath = value
   483  		case "env":
   484  			fs.Env = value
   485  		default:
   486  			return nil, errors.Errorf("unexpected key '%s' in '%s'", key, field)
   487  		}
   488  	}
   489  	if typ == "env" && fs.Env == "" {
   490  		fs.Env = fs.FilePath
   491  		fs.FilePath = ""
   492  	}
   493  	return &fs, nil
   494  }
   495  
   496  func parseSSHSpecs(sl []string) (session.Attachable, error) {
   497  	configs := make([]sshprovider.AgentConfig, 0, len(sl))
   498  	for _, v := range sl {
   499  		c := parseSSH(v)
   500  		configs = append(configs, *c)
   501  	}
   502  	return sshprovider.NewSSHAgentProvider(configs)
   503  }
   504  
   505  func parseSSH(value string) *sshprovider.AgentConfig {
   506  	parts := strings.SplitN(value, "=", 2)
   507  	cfg := sshprovider.AgentConfig{
   508  		ID: parts[0],
   509  	}
   510  	if len(parts) > 1 {
   511  		cfg.Paths = strings.Split(parts[1], ",")
   512  	}
   513  	return &cfg
   514  }