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