github.com/containerd/nerdctl@v1.7.7/pkg/cmd/builder/build.go (about)

     1  /*
     2     Copyright The containerd Authors.
     3  
     4     Licensed under the Apache License, Version 2.0 (the "License");
     5     you may not use this file except in compliance with the License.
     6     You may obtain a copy of the License at
     7  
     8         http://www.apache.org/licenses/LICENSE-2.0
     9  
    10     Unless required by applicable law or agreed to in writing, software
    11     distributed under the License is distributed on an "AS IS" BASIS,
    12     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13     See the License for the specific language governing permissions and
    14     limitations under the License.
    15  */
    16  
    17  package builder
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	"os"
    26  	"os/exec"
    27  	"path/filepath"
    28  	"strconv"
    29  	"strings"
    30  
    31  	"github.com/containerd/containerd"
    32  	"github.com/containerd/containerd/images"
    33  	"github.com/containerd/containerd/images/archive"
    34  	"github.com/containerd/errdefs"
    35  	"github.com/containerd/log"
    36  	"github.com/containerd/nerdctl/pkg/api/types"
    37  	"github.com/containerd/nerdctl/pkg/buildkitutil"
    38  	"github.com/containerd/nerdctl/pkg/clientutil"
    39  	"github.com/containerd/nerdctl/pkg/platformutil"
    40  	"github.com/containerd/nerdctl/pkg/strutil"
    41  	"github.com/containerd/platforms"
    42  	dockerreference "github.com/distribution/reference"
    43  )
    44  
    45  func Build(ctx context.Context, client *containerd.Client, options types.BuilderBuildOptions) error {
    46  	buildctlBinary, buildctlArgs, needsLoading, metaFile, tags, cleanup, err := generateBuildctlArgs(ctx, client, options)
    47  	if err != nil {
    48  		return err
    49  	}
    50  	if cleanup != nil {
    51  		defer cleanup()
    52  	}
    53  
    54  	log.L.Debugf("running %s %v", buildctlBinary, buildctlArgs)
    55  	buildctlCmd := exec.Command(buildctlBinary, buildctlArgs...)
    56  	buildctlCmd.Env = os.Environ()
    57  
    58  	var buildctlStdout io.Reader
    59  	if needsLoading {
    60  		buildctlStdout, err = buildctlCmd.StdoutPipe()
    61  		if err != nil {
    62  			return err
    63  		}
    64  	} else {
    65  		buildctlCmd.Stdout = options.Stdout
    66  	}
    67  	if !options.Quiet {
    68  		buildctlCmd.Stderr = options.Stderr
    69  	}
    70  
    71  	if err := buildctlCmd.Start(); err != nil {
    72  		return err
    73  	}
    74  
    75  	if needsLoading {
    76  		platMC, err := platformutil.NewMatchComparer(false, options.Platform)
    77  		if err != nil {
    78  			return err
    79  		}
    80  		if err = loadImage(ctx, buildctlStdout, options.GOptions.Namespace, options.GOptions.Address, options.GOptions.Snapshotter, options.Stdout, platMC, options.Quiet); err != nil {
    81  			return err
    82  		}
    83  	}
    84  
    85  	if err = buildctlCmd.Wait(); err != nil {
    86  		return err
    87  	}
    88  
    89  	if options.IidFile != "" {
    90  		id, err := getDigestFromMetaFile(metaFile)
    91  		if err != nil {
    92  			return err
    93  		}
    94  		if err := os.WriteFile(options.IidFile, []byte(id), 0644); err != nil {
    95  			return err
    96  		}
    97  	}
    98  
    99  	if len(tags) > 1 {
   100  		log.L.Debug("Found more than 1 tag")
   101  		imageService := client.ImageService()
   102  		image, err := imageService.Get(ctx, tags[0])
   103  		if err != nil {
   104  			return fmt.Errorf("unable to tag image: %s", err)
   105  		}
   106  		for _, targetRef := range tags[1:] {
   107  			image.Name = targetRef
   108  			if _, err := imageService.Create(ctx, image); err != nil {
   109  				// if already exists; skip.
   110  				if errors.Is(err, errdefs.ErrAlreadyExists) {
   111  					continue
   112  				}
   113  				return fmt.Errorf("unable to tag image: %s", err)
   114  			}
   115  		}
   116  	}
   117  
   118  	return nil
   119  }
   120  
   121  // TODO: This struct and `loadImage` are duplicated with the code in `cmd/load.go`, remove it after `load.go` has been refactor
   122  type readCounter struct {
   123  	io.Reader
   124  	N int
   125  }
   126  
   127  func loadImage(ctx context.Context, in io.Reader, namespace, address, snapshotter string, output io.Writer, platMC platforms.MatchComparer, quiet bool) error {
   128  	// In addition to passing WithImagePlatform() to client.Import(), we also need to pass WithDefaultPlatform() to NewClient().
   129  	// Otherwise unpacking may fail.
   130  	client, ctx, cancel, err := clientutil.NewClient(ctx, namespace, address, containerd.WithDefaultPlatform(platMC))
   131  	if err != nil {
   132  		return err
   133  	}
   134  	defer func() {
   135  		cancel()
   136  		client.Close()
   137  	}()
   138  	r := &readCounter{Reader: in}
   139  	imgs, err := client.Import(ctx, r, containerd.WithDigestRef(archive.DigestTranslator(snapshotter)), containerd.WithSkipDigestRef(func(name string) bool { return name != "" }), containerd.WithImportPlatform(platMC))
   140  	if err != nil {
   141  		if r.N == 0 {
   142  			// Avoid confusing "unrecognized image format"
   143  			return errors.New("no image was built")
   144  		}
   145  		if errors.Is(err, images.ErrEmptyWalk) {
   146  			err = fmt.Errorf("%w (Hint: set `--platform=PLATFORM` or `--all-platforms`)", err)
   147  		}
   148  		return err
   149  	}
   150  	for _, img := range imgs {
   151  		image := containerd.NewImageWithPlatform(client, img, platMC)
   152  
   153  		// TODO: Show unpack status
   154  		if !quiet {
   155  			fmt.Fprintf(output, "unpacking %s (%s)...\n", img.Name, img.Target.Digest)
   156  		}
   157  		err = image.Unpack(ctx, snapshotter)
   158  		if err != nil {
   159  			return err
   160  		}
   161  		if quiet {
   162  			fmt.Fprintln(output, img.Target.Digest)
   163  		} else {
   164  			fmt.Fprintf(output, "Loaded image: %s\n", img.Name)
   165  		}
   166  	}
   167  
   168  	return nil
   169  }
   170  
   171  func generateBuildctlArgs(ctx context.Context, client *containerd.Client, options types.BuilderBuildOptions) (buildCtlBinary string,
   172  	buildctlArgs []string, needsLoading bool, metaFile string, tags []string, cleanup func(), err error) {
   173  
   174  	buildctlBinary, err := buildkitutil.BuildctlBinary()
   175  	if err != nil {
   176  		return "", nil, false, "", nil, nil, err
   177  	}
   178  
   179  	output := options.Output
   180  	if output == "" {
   181  		info, err := client.Server(ctx)
   182  		if err != nil {
   183  			return "", nil, false, "", nil, nil, err
   184  		}
   185  		sharable, err := isImageSharable(options.BuildKitHost, options.GOptions.Namespace, info.UUID, options.GOptions.Snapshotter, options.Platform)
   186  		if err != nil {
   187  			return "", nil, false, "", nil, nil, err
   188  		}
   189  		if sharable {
   190  			output = "type=image,unpack=true" // ensure the target stage is unlazied (needed for any snapshotters)
   191  		} else {
   192  			output = "type=docker"
   193  			if len(options.Platform) > 1 {
   194  				// For avoiding `error: failed to solve: docker exporter does not currently support exporting manifest lists`
   195  				// TODO: consider using type=oci for single-options.Platform build too
   196  				output = "type=oci"
   197  			}
   198  			needsLoading = true
   199  		}
   200  	} else {
   201  		if !strings.Contains(output, "type=") {
   202  			// should accept --output <DIR> as an alias of --output
   203  			// type=local,dest=<DIR>
   204  			output = fmt.Sprintf("type=local,dest=%s", output)
   205  		}
   206  		if strings.Contains(output, "type=docker") || strings.Contains(output, "type=oci") {
   207  			needsLoading = true
   208  		}
   209  	}
   210  	if tags = strutil.DedupeStrSlice(options.Tag); len(tags) > 0 {
   211  		ref := tags[0]
   212  		named, err := dockerreference.ParseNormalizedNamed(ref)
   213  		if err != nil {
   214  			return "", nil, false, "", nil, nil, err
   215  		}
   216  		output += ",name=" + dockerreference.TagNameOnly(named).String()
   217  
   218  		// pick the first tag and add it to output
   219  		for idx, tag := range tags {
   220  			named, err := dockerreference.ParseNormalizedNamed(tag)
   221  			if err != nil {
   222  				return "", nil, false, "", nil, nil, err
   223  			}
   224  			tags[idx] = dockerreference.TagNameOnly(named).String()
   225  		}
   226  	} else if len(tags) == 0 {
   227  		output = output + ",dangling-name-prefix=<none>"
   228  	}
   229  
   230  	buildctlArgs = buildkitutil.BuildctlBaseArgs(options.BuildKitHost)
   231  
   232  	buildctlArgs = append(buildctlArgs, []string{
   233  		"build",
   234  		"--progress=" + options.Progress,
   235  		"--frontend=dockerfile.v0",
   236  		"--local=context=" + options.BuildContext,
   237  		"--output=" + output,
   238  	}...)
   239  
   240  	dir := options.BuildContext
   241  	file := buildkitutil.DefaultDockerfileName
   242  	if options.File != "" {
   243  		if options.File == "-" {
   244  			// Super Warning: this is a special trick to update the dir variable, Don't move this line!!!!!!
   245  			var err error
   246  			dir, err = buildkitutil.WriteTempDockerfile(options.Stdin)
   247  			if err != nil {
   248  				return "", nil, false, "", nil, nil, err
   249  			}
   250  			cleanup = func() {
   251  				os.RemoveAll(dir)
   252  			}
   253  		} else {
   254  			dir, file = filepath.Split(options.File)
   255  		}
   256  
   257  		if dir == "" {
   258  			dir = "."
   259  		}
   260  	}
   261  	dir, file, err = buildkitutil.BuildKitFile(dir, file)
   262  	if err != nil {
   263  		return "", nil, false, "", nil, nil, err
   264  	}
   265  
   266  	buildctlArgs = append(buildctlArgs, "--local=dockerfile="+dir)
   267  	buildctlArgs = append(buildctlArgs, "--opt=filename="+file)
   268  
   269  	if options.Target != "" {
   270  		buildctlArgs = append(buildctlArgs, "--opt=target="+options.Target)
   271  	}
   272  
   273  	if len(options.Platform) > 0 {
   274  		buildctlArgs = append(buildctlArgs, "--opt=platform="+strings.Join(options.Platform, ","))
   275  	}
   276  
   277  	seenBuildArgs := make(map[string]struct{})
   278  	for _, ba := range strutil.DedupeStrSlice(options.BuildArgs) {
   279  		arr := strings.Split(ba, "=")
   280  		seenBuildArgs[arr[0]] = struct{}{}
   281  		if len(arr) == 1 && len(arr[0]) > 0 {
   282  			// Avoid masking default build arg value from Dockerfile if environment variable is not set
   283  			// https://github.com/moby/moby/issues/24101
   284  			val, ok := os.LookupEnv(arr[0])
   285  			if ok {
   286  				buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=build-arg:%s=%s", ba, val))
   287  			} else {
   288  				log.L.Debugf("ignoring unset build arg %q", ba)
   289  			}
   290  		} else if len(arr) > 1 && len(arr[0]) > 0 {
   291  			buildctlArgs = append(buildctlArgs, "--opt=build-arg:"+ba)
   292  
   293  			// Support `--build-arg BUILDKIT_INLINE_CACHE=1` for compatibility with `docker buildx build`
   294  			// https://github.com/docker/buildx/blob/v0.6.3/docs/reference/buildx_build.md#-export-build-cache-to-an-external-cache-destination---cache-to
   295  			if strings.HasPrefix(ba, "BUILDKIT_INLINE_CACHE=") {
   296  				bic := strings.TrimPrefix(ba, "BUILDKIT_INLINE_CACHE=")
   297  				bicParsed, err := strconv.ParseBool(bic)
   298  				if err == nil {
   299  					if bicParsed {
   300  						buildctlArgs = append(buildctlArgs, "--export-cache=type=inline")
   301  					}
   302  				} else {
   303  					log.L.WithError(err).Warnf("invalid BUILDKIT_INLINE_CACHE: %q", bic)
   304  				}
   305  			}
   306  		} else {
   307  			return "", nil, false, "", nil, nil, fmt.Errorf("invalid build arg %q", ba)
   308  		}
   309  	}
   310  
   311  	// Propagate SOURCE_DATE_EPOCH from the client env
   312  	// https://github.com/docker/buildx/pull/1482
   313  	if v := os.Getenv("SOURCE_DATE_EPOCH"); v != "" {
   314  		if _, ok := seenBuildArgs["SOURCE_DATE_EPOCH"]; !ok {
   315  			buildctlArgs = append(buildctlArgs, "--opt=build-arg:SOURCE_DATE_EPOCH="+v)
   316  		}
   317  	}
   318  
   319  	for _, l := range strutil.DedupeStrSlice(options.Label) {
   320  		buildctlArgs = append(buildctlArgs, "--opt=label:"+l)
   321  	}
   322  
   323  	if options.NoCache {
   324  		buildctlArgs = append(buildctlArgs, "--no-cache")
   325  	}
   326  
   327  	for _, s := range strutil.DedupeStrSlice(options.Secret) {
   328  		buildctlArgs = append(buildctlArgs, "--secret="+s)
   329  	}
   330  
   331  	for _, s := range strutil.DedupeStrSlice(options.Allow) {
   332  		buildctlArgs = append(buildctlArgs, "--allow="+s)
   333  	}
   334  
   335  	for _, s := range strutil.DedupeStrSlice(options.SSH) {
   336  		buildctlArgs = append(buildctlArgs, "--ssh="+s)
   337  	}
   338  
   339  	for _, s := range strutil.DedupeStrSlice(options.CacheFrom) {
   340  		if !strings.Contains(s, "type=") {
   341  			s = "type=registry,ref=" + s
   342  		}
   343  		buildctlArgs = append(buildctlArgs, "--import-cache="+s)
   344  	}
   345  
   346  	for _, s := range strutil.DedupeStrSlice(options.CacheTo) {
   347  		if !strings.Contains(s, "type=") {
   348  			s = "type=registry,ref=" + s
   349  		}
   350  		buildctlArgs = append(buildctlArgs, "--export-cache="+s)
   351  	}
   352  
   353  	if !options.Rm {
   354  		log.L.Warn("ignoring deprecated flag: '--rm=false'")
   355  	}
   356  
   357  	if options.IidFile != "" {
   358  		file, err := os.CreateTemp("", "buildkit-meta-*")
   359  		if err != nil {
   360  			return "", nil, false, "", nil, cleanup, err
   361  		}
   362  		defer file.Close()
   363  		metaFile = file.Name()
   364  		buildctlArgs = append(buildctlArgs, "--metadata-file="+metaFile)
   365  	}
   366  
   367  	if options.NetworkMode != "" {
   368  		switch options.NetworkMode {
   369  		case "none":
   370  			buildctlArgs = append(buildctlArgs, "--opt=force-network-mode="+options.NetworkMode)
   371  		case "host":
   372  			buildctlArgs = append(buildctlArgs, "--opt=force-network-mode="+options.NetworkMode, "--allow=network.host", "--allow=security.insecure")
   373  		case "", "default":
   374  		default:
   375  			log.L.Debugf("ignoring network build arg %s", options.NetworkMode)
   376  		}
   377  	}
   378  
   379  	return buildctlBinary, buildctlArgs, needsLoading, metaFile, tags, cleanup, nil
   380  }
   381  
   382  func getDigestFromMetaFile(path string) (string, error) {
   383  	data, err := os.ReadFile(path)
   384  	if err != nil {
   385  		return "", err
   386  	}
   387  	defer os.Remove(path)
   388  
   389  	metadata := map[string]json.RawMessage{}
   390  	if err := json.Unmarshal(data, &metadata); err != nil {
   391  		log.L.WithError(err).Errorf("failed to unmarshal metadata file %s", path)
   392  		return "", err
   393  	}
   394  	digestRaw, ok := metadata["containerimage.digest"]
   395  	if !ok {
   396  		return "", errors.New("failed to find containerimage.digest in metadata file")
   397  	}
   398  	var digest string
   399  	if err := json.Unmarshal(digestRaw, &digest); err != nil {
   400  		log.L.WithError(err).Errorf("failed to unmarshal digset")
   401  		return "", err
   402  	}
   403  	return digest, nil
   404  }
   405  
   406  func isImageSharable(buildkitHost, namespace, uuid, snapshotter string, platform []string) (bool, error) {
   407  	labels, err := buildkitutil.GetWorkerLabels(buildkitHost)
   408  	if err != nil {
   409  		return false, err
   410  	}
   411  	log.L.Debugf("worker labels: %+v", labels)
   412  	executor, ok := labels["org.mobyproject.buildkit.worker.executor"]
   413  	if !ok {
   414  		return false, nil
   415  	}
   416  	containerdUUID, ok := labels["org.mobyproject.buildkit.worker.containerd.uuid"]
   417  	if !ok {
   418  		return false, nil
   419  	}
   420  	containerdNamespace, ok := labels["org.mobyproject.buildkit.worker.containerd.namespace"]
   421  	if !ok {
   422  		return false, nil
   423  	}
   424  	workerSnapshotter, ok := labels["org.mobyproject.buildkit.worker.snapshotter"]
   425  	if !ok {
   426  		return false, nil
   427  	}
   428  	// NOTE: It's possible that BuildKit doesn't download the base image of non-default platform (e.g. when the provided
   429  	//       Dockerfile doesn't contain instructions require base images like RUN) even if `--output type=image,unpack=true`
   430  	//       is passed to BuildKit. Thus, we need to use `type=docker` or `type=oci` when nerdctl builds non-default platform
   431  	//       image using `platform` option.
   432  	return executor == "containerd" && containerdUUID == uuid && containerdNamespace == namespace && workerSnapshotter == snapshotter && len(platform) == 0, nil
   433  }