github.com/buildtool/build-tools@v0.2.29-0.20240322150259-6a1d0a553c23/pkg/build/build.go (about)

     1  // MIT License
     2  //
     3  // Copyright (c) 2018 buildtool
     4  //
     5  // Permission is hereby granted, free of charge, to any person obtaining a copy
     6  // of this software and associated documentation files (the "Software"), to deal
     7  // in the Software without restriction, including without limitation the rights
     8  // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     9  // copies of the Software, and to permit persons to whom the Software is
    10  // furnished to do so, subject to the following conditions:
    11  //
    12  // The above copyright notice and this permission notice shall be included in all
    13  // copies or substantial portions of the Software.
    14  //
    15  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    16  // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    17  // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    18  // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    19  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    20  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    21  // SOFTWARE.
    22  
    23  package build
    24  
    25  import (
    26  	"bytes"
    27  	"context"
    28  	"crypto/rand"
    29  	"crypto/sha256"
    30  	"encoding/hex"
    31  	"encoding/json"
    32  	"fmt"
    33  	"net"
    34  	"os"
    35  	"path/filepath"
    36  	"strings"
    37  
    38  	"github.com/apex/log"
    39  	"github.com/aws/aws-sdk-go-v2/aws"
    40  	"github.com/docker/docker/api/types"
    41  	"github.com/docker/docker/pkg/jsonmessage"
    42  	"github.com/docker/docker/pkg/stringid"
    43  	controlapi "github.com/moby/buildkit/api/services/control"
    44  	"github.com/moby/buildkit/client"
    45  	"github.com/moby/buildkit/session"
    46  	"github.com/moby/buildkit/session/filesync"
    47  	"github.com/moby/buildkit/util/progress/progressui"
    48  	"github.com/tonistiigi/fsutil"
    49  	"golang.org/x/sync/errgroup"
    50  	"gopkg.in/yaml.v3"
    51  
    52  	"github.com/buildtool/build-tools/pkg/args"
    53  	"github.com/buildtool/build-tools/pkg/ci"
    54  	"github.com/buildtool/build-tools/pkg/config"
    55  	"github.com/buildtool/build-tools/pkg/docker"
    56  )
    57  
    58  type Args struct {
    59  	args.Globals
    60  	Dockerfile string   `name:"file" short:"f" help:"name of the Dockerfile to use." default:"Dockerfile"`
    61  	BuildArgs  []string `name:"build-arg" type:"list" help:"additional docker build-args to use, see https://docs.docker.com/engine/reference/commandline/build/ for more information."`
    62  	NoLogin    bool     `help:"disable login to docker registry" default:"false" `
    63  	NoPull     bool     `help:"disable pulling latest from docker registry" default:"false"`
    64  	Platform   string   `help:"specify target platform to build" default:""`
    65  }
    66  
    67  func DoBuild(dir string, buildArgs Args) error {
    68  	dkrClient, err := dockerClient()
    69  	if err != nil {
    70  		return err
    71  	}
    72  	return build(dkrClient, dir, buildArgs)
    73  }
    74  
    75  var dockerClient = docker.DefaultClient
    76  
    77  var setupSession = provideSession
    78  
    79  func provideSession(dir string) Session {
    80  	s, err := session.NewSession(context.Background(), filepath.Base(dir), getBuildSharedKey(dir))
    81  	if err != nil {
    82  		panic("session.NewSession changed behaviour and returned an error. Create an issue at https://github.com/buildtool/build-tools/issues/new")
    83  	}
    84  	if s == nil {
    85  		panic("session.NewSession changed behaviour and did not return a session. Create an issue at https://github.com/buildtool/build-tools/issues/new")
    86  	}
    87  	return s
    88  }
    89  
    90  func build(client docker.Client, dir string, buildVars Args) error {
    91  	cfg, err := config.Load(dir)
    92  	if err != nil {
    93  		return err
    94  	}
    95  	currentCI := cfg.CurrentCI()
    96  	if buildVars.Platform != "" {
    97  		log.Infof("building for platform <green>%s</green>\n", buildVars.Platform)
    98  	}
    99  
   100  	log.Debugf("Using CI <green>%s</green>\n", currentCI.Name())
   101  
   102  	currentRegistry := cfg.CurrentRegistry()
   103  	log.Debugf("Using registry <green>%s</green>\n", currentRegistry.Name())
   104  	var authenticator docker.Authenticator
   105  	if buildVars.NoLogin {
   106  		log.Debugf("Login <yellow>disabled</yellow>\n")
   107  	} else {
   108  		log.Debugf("Authenticating against registry <green>%s</green>\n", currentRegistry.Name())
   109  		if err := currentRegistry.Login(client); err != nil {
   110  			return err
   111  		}
   112  		authenticator = docker.NewAuthenticator(currentRegistry.RegistryUrl(), currentRegistry.GetAuthConfig())
   113  	}
   114  
   115  	content, err := os.ReadFile(filepath.Join(dir, buildVars.Dockerfile))
   116  	if err != nil {
   117  		log.Error(fmt.Sprintf("<red>%s</red>", err.Error()))
   118  		return err
   119  	}
   120  	stages := docker.FindStages(string(content))
   121  	if !ci.IsValid(currentCI) {
   122  		return fmt.Errorf("commit and/or branch information is <red>missing</red> (perhaps you're not in a Git repository or forgot to set environment variables?)")
   123  	}
   124  
   125  	commit := currentCI.Commit()
   126  	branch := currentCI.BranchReplaceSlash()
   127  	log.Debugf("Using build variables commit <green>%s</green> on branch <green>%s</green>\n", commit, branch)
   128  	var tags []string
   129  	branchTag := docker.Tag(currentRegistry.RegistryUrl(), currentCI.BuildName(), branch)
   130  	latestTag := docker.Tag(currentRegistry.RegistryUrl(), currentCI.BuildName(), "latest")
   131  	tags = append(tags, []string{
   132  		docker.Tag(currentRegistry.RegistryUrl(), currentCI.BuildName(), commit),
   133  		branchTag,
   134  	}...)
   135  	if currentCI.Branch() == "master" || currentCI.Branch() == "main" {
   136  		tags = append(tags, latestTag)
   137  	}
   138  
   139  	caches := []string{branchTag, latestTag}
   140  
   141  	buildArgs := map[string]*string{
   142  		"BUILDKIT_INLINE_CACHE": aws.String("1"),
   143  		"CI_COMMIT":             &commit,
   144  		"CI_BRANCH":             &branch,
   145  	}
   146  	for _, arg := range buildVars.BuildArgs {
   147  		split := strings.Split(arg, "=")
   148  		key := split[0]
   149  		value := strings.Join(split[1:], "=")
   150  		if len(split) > 1 && len(value) > 0 {
   151  			buildArgs[key] = &value
   152  		} else {
   153  			if env, exists := os.LookupEnv(key); exists {
   154  				buildArgs[key] = &env
   155  			} else {
   156  				log.Debugf("ignoring build-arg %s\n", key)
   157  			}
   158  		}
   159  	}
   160  
   161  	for _, stage := range stages {
   162  		tag := docker.Tag(currentRegistry.RegistryUrl(), currentCI.BuildName(), stage)
   163  		caches = append([]string{tag}, caches...)
   164  		err := buildStage(client, dir, buildVars, buildArgs, []string{tag}, caches, stage, authenticator)
   165  		if err != nil {
   166  			return err
   167  		}
   168  	}
   169  
   170  	return buildStage(client, dir, buildVars, buildArgs, tags, caches, "", authenticator)
   171  }
   172  
   173  func buildStage(client docker.Client, dir string, buildVars Args, buildArgs map[string]*string, tags []string, caches []string, stage string, authenticator docker.Authenticator) error {
   174  	s := setupSession(dir)
   175  	if authenticator != nil {
   176  		s.Allow(authenticator)
   177  	}
   178  	fs, err := fsutil.NewFS(dir)
   179  	if err != nil {
   180  		return err
   181  	}
   182  	s.Allow(filesync.NewFSSyncProvider(filesync.StaticDirSource{
   183  		"context":    fs,
   184  		"dockerfile": fs,
   185  	}))
   186  	s.Allow(filesync.NewFSSyncTarget(filesync.WithFSSyncDir(0, "exported")))
   187  
   188  	eg, ctx := errgroup.WithContext(context.Background())
   189  	dialSession := func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) {
   190  		return client.DialHijack(ctx, "/session", proto, meta)
   191  	}
   192  	eg.Go(func() error {
   193  		return s.Run(context.TODO(), dialSession)
   194  	})
   195  	eg.Go(func() error {
   196  		defer func() { // make sure the Status ends cleanly on build errors
   197  			_ = s.Close()
   198  		}()
   199  		var outputs []types.ImageBuildOutput
   200  		if strings.HasPrefix(stage, "export") {
   201  			outputs = append(outputs, types.ImageBuildOutput{
   202  				Type:  "local",
   203  				Attrs: map[string]string{},
   204  			})
   205  		}
   206  		sessionID := s.ID()
   207  		return doBuild(ctx, client, eg, buildVars.Dockerfile, buildArgs, tags, caches, stage, !buildVars.NoPull, sessionID, outputs, buildVars.Platform)
   208  	})
   209  	return eg.Wait()
   210  }
   211  
   212  func doBuild(ctx context.Context, dkrClient docker.Client, eg *errgroup.Group, dockerfile string, args map[string]*string, tags, caches []string, target string, pullParent bool, sessionID string, outputs []types.ImageBuildOutput, platform string) (finalErr error) {
   213  	buildID := stringid.GenerateRandomID()
   214  	options := types.ImageBuildOptions{
   215  		BuildArgs:     args,
   216  		BuildID:       buildID,
   217  		CacheFrom:     caches,
   218  		Dockerfile:    dockerfile,
   219  		Outputs:       outputs,
   220  		PullParent:    pullParent,
   221  		MemorySwap:    -1,
   222  		RemoteContext: "client-session",
   223  		Remove:        true,
   224  		SessionID:     sessionID,
   225  		ShmSize:       256 * 1024 * 1024,
   226  		Tags:          tags,
   227  		Target:        target,
   228  		Version:       types.BuilderBuildKit,
   229  		Platform:      platform,
   230  	}
   231  	logVerbose(options)
   232  	var response types.ImageBuildResponse
   233  	var err error
   234  	response, err = dkrClient.ImageBuild(context.Background(), nil, options)
   235  	if err != nil {
   236  		return err
   237  	}
   238  	defer func() { _ = response.Body.Close() }()
   239  
   240  	done := make(chan struct{})
   241  	defer close(done)
   242  	eg.Go(func() error {
   243  		select {
   244  		case <-ctx.Done():
   245  			return dkrClient.BuildCancel(context.TODO(), buildID)
   246  		case <-done:
   247  		}
   248  		return nil
   249  	})
   250  
   251  	tracer := newTracer()
   252  
   253  	displayStatus(os.Stderr, tracer.displayCh, eg)
   254  	defer close(tracer.displayCh)
   255  
   256  	buf := &bytes.Buffer{}
   257  	imageID := ""
   258  	writeAux := func(msg jsonmessage.JSONMessage) {
   259  		if msg.ID == "moby.image.id" {
   260  			var result types.BuildResult
   261  			if err := json.Unmarshal(*msg.Aux, &result); err != nil {
   262  				log.Errorf("failed to parse aux message: %v", err)
   263  			}
   264  			imageID = result.ID
   265  			return
   266  		}
   267  		tracer.write(msg)
   268  	}
   269  
   270  	err = jsonmessage.DisplayJSONMessagesStream(response.Body, buf, os.Stdout.Fd(), true, writeAux)
   271  	if err != nil {
   272  		if jerr, ok := err.(*jsonmessage.JSONError); ok {
   273  			// If no error code is set, default to 1
   274  			if jerr.Code == 0 {
   275  				jerr.Code = 1
   276  			}
   277  			return fmt.Errorf("code: %d, status: %s", jerr.Code, jerr.Message)
   278  		}
   279  	}
   280  
   281  	imageID = buf.String()
   282  	log.Info(imageID)
   283  
   284  	return nil
   285  }
   286  
   287  func displayStatus(out *os.File, displayCh chan *client.SolveStatus, eg *errgroup.Group) {
   288  	// not using shared context to not disrupt display but let it finish reporting errors
   289  	display, err := progressui.NewDisplay(out, progressui.AutoMode)
   290  	if err != nil {
   291  		eg.Go(func() error {
   292  			return err
   293  		})
   294  	}
   295  	eg.Go(func() error {
   296  		_, err := display.UpdateFrom(context.TODO(), displayCh)
   297  		return err
   298  	})
   299  }
   300  
   301  func logVerbose(options types.ImageBuildOptions) {
   302  	loggableOptions := options
   303  	loggableOptions.AuthConfigs = nil
   304  	loggableOptions.BuildID = ""
   305  	loggableOptions.SessionID = ""
   306  	marshal, _ := yaml.Marshal(loggableOptions)
   307  	log.Debugf("performing docker build with options (auths removed):\n%s\n", marshal)
   308  }
   309  
   310  func getBuildSharedKey(dir string) string {
   311  	// build session id hash of build dir with node based randomness
   312  	s := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", tryNodeIdentifier(), dir)))
   313  	return hex.EncodeToString(s[:])
   314  }
   315  
   316  func tryNodeIdentifier() string {
   317  	out := filepath.Join(os.TempDir(), ".buildtools") // return config dir as default on permission error
   318  	if err := os.MkdirAll(out, 0700); err == nil {
   319  		sessionFile := filepath.Join(out, ".buildNodeID")
   320  		if _, err := os.Lstat(sessionFile); err != nil {
   321  			if os.IsNotExist(err) { // create a new file with stored randomness
   322  				b := make([]byte, 32)
   323  				if _, err := rand.Read(b); err != nil {
   324  					return out
   325  				}
   326  				if err := os.WriteFile(sessionFile, []byte(hex.EncodeToString(b)), 0600); err != nil {
   327  					return out
   328  				}
   329  			}
   330  		}
   331  
   332  		dt, err := os.ReadFile(sessionFile)
   333  		if err == nil {
   334  			return string(dt)
   335  		}
   336  	}
   337  	return out
   338  }
   339  
   340  type tracer struct {
   341  	displayCh chan *client.SolveStatus
   342  }
   343  
   344  func newTracer() *tracer {
   345  	return &tracer{
   346  		displayCh: make(chan *client.SolveStatus),
   347  	}
   348  }
   349  
   350  func (t *tracer) write(msg jsonmessage.JSONMessage) {
   351  	var resp controlapi.StatusResponse
   352  
   353  	if msg.ID != "moby.buildkit.trace" {
   354  		return
   355  	}
   356  
   357  	var dt []byte
   358  	// ignoring all messages that are not understood
   359  	if err := json.Unmarshal(*msg.Aux, &dt); err != nil {
   360  		return
   361  	}
   362  	if err := (&resp).Unmarshal(dt); err != nil {
   363  		return
   364  	}
   365  
   366  	s := client.SolveStatus{}
   367  	for _, v := range resp.Vertexes {
   368  		s.Vertexes = append(s.Vertexes, &client.Vertex{
   369  			Digest:    v.Digest,
   370  			Inputs:    v.Inputs,
   371  			Name:      v.Name,
   372  			Started:   v.Started,
   373  			Completed: v.Completed,
   374  			Error:     v.Error,
   375  			Cached:    v.Cached,
   376  		})
   377  	}
   378  	for _, v := range resp.Statuses {
   379  		s.Statuses = append(s.Statuses, &client.VertexStatus{
   380  			ID:        v.ID,
   381  			Vertex:    v.Vertex,
   382  			Name:      v.Name,
   383  			Total:     v.Total,
   384  			Current:   v.Current,
   385  			Timestamp: v.Timestamp,
   386  			Started:   v.Started,
   387  			Completed: v.Completed,
   388  		})
   389  	}
   390  	for _, v := range resp.Logs {
   391  		s.Logs = append(s.Logs, &client.VertexLog{
   392  			Vertex:    v.Vertex,
   393  			Stream:    int(v.Stream),
   394  			Data:      v.Msg,
   395  			Timestamp: v.Timestamp,
   396  		})
   397  	}
   398  
   399  	t.displayCh <- &s
   400  }