github.com/containerd/nerdctl/v2@v2.0.0-beta.5.0.20240520001846-b5758f54fa28/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/errdefs"
    33  	"github.com/containerd/containerd/images"
    34  	"github.com/containerd/containerd/images/archive"
    35  	dockerreference "github.com/containerd/containerd/reference/docker"
    36  	"github.com/containerd/log"
    37  	"github.com/containerd/nerdctl/v2/pkg/api/types"
    38  	"github.com/containerd/nerdctl/v2/pkg/buildkitutil"
    39  	"github.com/containerd/nerdctl/v2/pkg/clientutil"
    40  	"github.com/containerd/nerdctl/v2/pkg/platformutil"
    41  	"github.com/containerd/nerdctl/v2/pkg/strutil"
    42  	"github.com/containerd/platforms"
    43  )
    44  
    45  type PlatformParser interface {
    46  	Parse(platform string) (platforms.Platform, error)
    47  	DefaultSpec() platforms.Platform
    48  }
    49  
    50  type platformParser struct{}
    51  
    52  func (p platformParser) Parse(platform string) (platforms.Platform, error) {
    53  	return platforms.Parse(platform)
    54  }
    55  
    56  func (p platformParser) DefaultSpec() platforms.Platform {
    57  	return platforms.DefaultSpec()
    58  }
    59  
    60  func Build(ctx context.Context, client *containerd.Client, options types.BuilderBuildOptions) error {
    61  	buildctlBinary, buildctlArgs, needsLoading, metaFile, tags, cleanup, err := generateBuildctlArgs(ctx, client, options)
    62  	if err != nil {
    63  		return err
    64  	}
    65  	if cleanup != nil {
    66  		defer cleanup()
    67  	}
    68  
    69  	log.L.Debugf("running %s %v", buildctlBinary, buildctlArgs)
    70  	buildctlCmd := exec.Command(buildctlBinary, buildctlArgs...)
    71  	buildctlCmd.Env = os.Environ()
    72  
    73  	var buildctlStdout io.Reader
    74  	if needsLoading {
    75  		buildctlStdout, err = buildctlCmd.StdoutPipe()
    76  		if err != nil {
    77  			return err
    78  		}
    79  	} else {
    80  		buildctlCmd.Stdout = options.Stdout
    81  	}
    82  	if !options.Quiet {
    83  		buildctlCmd.Stderr = options.Stderr
    84  	}
    85  
    86  	if err := buildctlCmd.Start(); err != nil {
    87  		return err
    88  	}
    89  
    90  	if needsLoading {
    91  		platMC, err := platformutil.NewMatchComparer(false, options.Platform)
    92  		if err != nil {
    93  			return err
    94  		}
    95  		if err = loadImage(ctx, buildctlStdout, options.GOptions.Namespace, options.GOptions.Address, options.GOptions.Snapshotter, options.Stdout, platMC, options.Quiet); err != nil {
    96  			return err
    97  		}
    98  	}
    99  
   100  	if err = buildctlCmd.Wait(); err != nil {
   101  		return err
   102  	}
   103  
   104  	if options.IidFile != "" {
   105  		id, err := getDigestFromMetaFile(metaFile)
   106  		if err != nil {
   107  			return err
   108  		}
   109  		if err := os.WriteFile(options.IidFile, []byte(id), 0644); err != nil {
   110  			return err
   111  		}
   112  	}
   113  
   114  	if len(tags) > 1 {
   115  		log.L.Debug("Found more than 1 tag")
   116  		imageService := client.ImageService()
   117  		image, err := imageService.Get(ctx, tags[0])
   118  		if err != nil {
   119  			return fmt.Errorf("unable to tag image: %s", err)
   120  		}
   121  		for _, targetRef := range tags[1:] {
   122  			image.Name = targetRef
   123  			if _, err := imageService.Create(ctx, image); err != nil {
   124  				// if already exists; skip.
   125  				if errors.Is(err, errdefs.ErrAlreadyExists) {
   126  					if err = imageService.Delete(ctx, targetRef); err != nil {
   127  						return err
   128  					}
   129  					if _, err = imageService.Create(ctx, image); err != nil {
   130  						return err
   131  					}
   132  					continue
   133  				}
   134  				return fmt.Errorf("unable to tag image: %s", err)
   135  			}
   136  		}
   137  	}
   138  
   139  	return nil
   140  }
   141  
   142  // TODO: This struct and `loadImage` are duplicated with the code in `cmd/load.go`, remove it after `load.go` has been refactor
   143  type readCounter struct {
   144  	io.Reader
   145  	N int
   146  }
   147  
   148  func loadImage(ctx context.Context, in io.Reader, namespace, address, snapshotter string, output io.Writer, platMC platforms.MatchComparer, quiet bool) error {
   149  	// In addition to passing WithImagePlatform() to client.Import(), we also need to pass WithDefaultPlatform() to NewClient().
   150  	// Otherwise unpacking may fail.
   151  	client, ctx, cancel, err := clientutil.NewClient(ctx, namespace, address, containerd.WithDefaultPlatform(platMC))
   152  	if err != nil {
   153  		return err
   154  	}
   155  	defer func() {
   156  		cancel()
   157  		client.Close()
   158  	}()
   159  	r := &readCounter{Reader: in}
   160  	imgs, err := client.Import(ctx, r, containerd.WithDigestRef(archive.DigestTranslator(snapshotter)), containerd.WithSkipDigestRef(func(name string) bool { return name != "" }), containerd.WithImportPlatform(platMC))
   161  	if err != nil {
   162  		if r.N == 0 {
   163  			// Avoid confusing "unrecognized image format"
   164  			return errors.New("no image was built")
   165  		}
   166  		if errors.Is(err, images.ErrEmptyWalk) {
   167  			err = fmt.Errorf("%w (Hint: set `--platform=PLATFORM` or `--all-platforms`)", err)
   168  		}
   169  		return err
   170  	}
   171  	for _, img := range imgs {
   172  		image := containerd.NewImageWithPlatform(client, img, platMC)
   173  
   174  		// TODO: Show unpack status
   175  		if !quiet {
   176  			fmt.Fprintf(output, "unpacking %s (%s)...\n", img.Name, img.Target.Digest)
   177  		}
   178  		err = image.Unpack(ctx, snapshotter)
   179  		if err != nil {
   180  			return err
   181  		}
   182  		if quiet {
   183  			fmt.Fprintln(output, img.Target.Digest)
   184  		} else {
   185  			fmt.Fprintf(output, "Loaded image: %s\n", img.Name)
   186  		}
   187  	}
   188  
   189  	return nil
   190  }
   191  
   192  func generateBuildctlArgs(ctx context.Context, client *containerd.Client, options types.BuilderBuildOptions) (buildCtlBinary string,
   193  	buildctlArgs []string, needsLoading bool, metaFile string, tags []string, cleanup func(), err error) {
   194  
   195  	buildctlBinary, err := buildkitutil.BuildctlBinary()
   196  	if err != nil {
   197  		return "", nil, false, "", nil, nil, err
   198  	}
   199  
   200  	output := options.Output
   201  	if output == "" {
   202  		info, err := client.Server(ctx)
   203  		if err != nil {
   204  			return "", nil, false, "", nil, nil, err
   205  		}
   206  		sharable, err := isImageSharable(options.BuildKitHost, options.GOptions.Namespace, info.UUID, options.GOptions.Snapshotter, options.Platform)
   207  		if err != nil {
   208  			return "", nil, false, "", nil, nil, err
   209  		}
   210  		if sharable {
   211  			output = "type=image,unpack=true" // ensure the target stage is unlazied (needed for any snapshotters)
   212  		} else {
   213  			output = "type=docker"
   214  			if len(options.Platform) > 1 {
   215  				// For avoiding `error: failed to solve: docker exporter does not currently support exporting manifest lists`
   216  				// TODO: consider using type=oci for single-options.Platform build too
   217  				output = "type=oci"
   218  			}
   219  			needsLoading = true
   220  		}
   221  	} else {
   222  		if !strings.Contains(output, "type=") {
   223  			// should accept --output <DIR> as an alias of --output
   224  			// type=local,dest=<DIR>
   225  			output = fmt.Sprintf("type=local,dest=%s", output)
   226  		}
   227  		if strings.Contains(output, "type=docker") || strings.Contains(output, "type=oci") {
   228  			needsLoading = true
   229  		}
   230  	}
   231  	if tags = strutil.DedupeStrSlice(options.Tag); len(tags) > 0 {
   232  		ref := tags[0]
   233  		named, err := dockerreference.ParseNormalizedNamed(ref)
   234  		if err != nil {
   235  			return "", nil, false, "", nil, nil, err
   236  		}
   237  		output += ",name=" + dockerreference.TagNameOnly(named).String()
   238  
   239  		// pick the first tag and add it to output
   240  		for idx, tag := range tags {
   241  			named, err := dockerreference.ParseNormalizedNamed(tag)
   242  			if err != nil {
   243  				return "", nil, false, "", nil, nil, err
   244  			}
   245  			tags[idx] = dockerreference.TagNameOnly(named).String()
   246  		}
   247  	} else if len(tags) == 0 {
   248  		output = output + ",dangling-name-prefix=<none>"
   249  	}
   250  
   251  	buildctlArgs = buildkitutil.BuildctlBaseArgs(options.BuildKitHost)
   252  
   253  	buildctlArgs = append(buildctlArgs, []string{
   254  		"build",
   255  		"--progress=" + options.Progress,
   256  		"--frontend=dockerfile.v0",
   257  		"--local=context=" + options.BuildContext,
   258  		"--output=" + output,
   259  	}...)
   260  
   261  	dir := options.BuildContext
   262  	file := buildkitutil.DefaultDockerfileName
   263  	if options.File != "" {
   264  		if options.File == "-" {
   265  			// Super Warning: this is a special trick to update the dir variable, Don't move this line!!!!!!
   266  			var err error
   267  			dir, err = buildkitutil.WriteTempDockerfile(options.Stdin)
   268  			if err != nil {
   269  				return "", nil, false, "", nil, nil, err
   270  			}
   271  			cleanup = func() {
   272  				os.RemoveAll(dir)
   273  			}
   274  		} else {
   275  			dir, file = filepath.Split(options.File)
   276  		}
   277  
   278  		if dir == "" {
   279  			dir = "."
   280  		}
   281  	}
   282  	dir, file, err = buildkitutil.BuildKitFile(dir, file)
   283  	if err != nil {
   284  		return "", nil, false, "", nil, nil, err
   285  	}
   286  
   287  	buildCtx, err := parseContextNames(options.ExtendedBuildContext)
   288  	if err != nil {
   289  		return "", nil, false, "", nil, nil, err
   290  	}
   291  
   292  	for k, v := range buildCtx {
   293  		isURL := strings.HasPrefix(v, "https://") || strings.HasPrefix(v, "http://")
   294  		isDockerImage := strings.HasPrefix(v, "docker-image://") || strings.HasPrefix(v, "target:")
   295  
   296  		if isURL || isDockerImage {
   297  			buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=context:%s=%s", k, v))
   298  			continue
   299  		}
   300  
   301  		path, err := filepath.Abs(v)
   302  		if err != nil {
   303  			return "", nil, false, "", nil, nil, err
   304  		}
   305  		buildctlArgs = append(buildctlArgs, fmt.Sprintf("--local=%s=%s", k, path))
   306  		buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=context:%s=local:%s", k, k))
   307  	}
   308  
   309  	buildctlArgs = append(buildctlArgs, "--local=dockerfile="+dir)
   310  	buildctlArgs = append(buildctlArgs, "--opt=filename="+file)
   311  
   312  	if options.Target != "" {
   313  		buildctlArgs = append(buildctlArgs, "--opt=target="+options.Target)
   314  	}
   315  
   316  	if len(options.Platform) > 0 {
   317  		buildctlArgs = append(buildctlArgs, "--opt=platform="+strings.Join(options.Platform, ","))
   318  	}
   319  
   320  	seenBuildArgs := make(map[string]struct{})
   321  	for _, ba := range strutil.DedupeStrSlice(options.BuildArgs) {
   322  		arr := strings.Split(ba, "=")
   323  		seenBuildArgs[arr[0]] = struct{}{}
   324  		if len(arr) == 1 && len(arr[0]) > 0 {
   325  			// Avoid masking default build arg value from Dockerfile if environment variable is not set
   326  			// https://github.com/moby/moby/issues/24101
   327  			val, ok := os.LookupEnv(arr[0])
   328  			if ok {
   329  				buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=build-arg:%s=%s", ba, val))
   330  			} else {
   331  				log.L.Debugf("ignoring unset build arg %q", ba)
   332  			}
   333  		} else if len(arr) > 1 && len(arr[0]) > 0 {
   334  			buildctlArgs = append(buildctlArgs, "--opt=build-arg:"+ba)
   335  
   336  			// Support `--build-arg BUILDKIT_INLINE_CACHE=1` for compatibility with `docker buildx build`
   337  			// https://github.com/docker/buildx/blob/v0.6.3/docs/reference/buildx_build.md#-export-build-cache-to-an-external-cache-destination---cache-to
   338  			if strings.HasPrefix(ba, "BUILDKIT_INLINE_CACHE=") {
   339  				bic := strings.TrimPrefix(ba, "BUILDKIT_INLINE_CACHE=")
   340  				bicParsed, err := strconv.ParseBool(bic)
   341  				if err == nil {
   342  					if bicParsed {
   343  						buildctlArgs = append(buildctlArgs, "--export-cache=type=inline")
   344  					}
   345  				} else {
   346  					log.L.WithError(err).Warnf("invalid BUILDKIT_INLINE_CACHE: %q", bic)
   347  				}
   348  			}
   349  		} else {
   350  			return "", nil, false, "", nil, nil, fmt.Errorf("invalid build arg %q", ba)
   351  		}
   352  	}
   353  
   354  	// Propagate SOURCE_DATE_EPOCH from the client env
   355  	// https://github.com/docker/buildx/pull/1482
   356  	if v := os.Getenv("SOURCE_DATE_EPOCH"); v != "" {
   357  		if _, ok := seenBuildArgs["SOURCE_DATE_EPOCH"]; !ok {
   358  			buildctlArgs = append(buildctlArgs, "--opt=build-arg:SOURCE_DATE_EPOCH="+v)
   359  		}
   360  	}
   361  
   362  	for _, l := range strutil.DedupeStrSlice(options.Label) {
   363  		buildctlArgs = append(buildctlArgs, "--opt=label:"+l)
   364  	}
   365  
   366  	if options.NoCache {
   367  		buildctlArgs = append(buildctlArgs, "--no-cache")
   368  	}
   369  
   370  	for _, s := range strutil.DedupeStrSlice(options.Secret) {
   371  		buildctlArgs = append(buildctlArgs, "--secret="+s)
   372  	}
   373  
   374  	for _, s := range strutil.DedupeStrSlice(options.Allow) {
   375  		buildctlArgs = append(buildctlArgs, "--allow="+s)
   376  	}
   377  
   378  	for _, s := range strutil.DedupeStrSlice(options.Attest) {
   379  		optAttestType, optAttestAttrs, _ := strings.Cut(s, ",")
   380  		if strings.HasPrefix(optAttestType, "type=") {
   381  			optAttestType := strings.TrimPrefix(optAttestType, "type=")
   382  			buildctlArgs = append(buildctlArgs, fmt.Sprintf("--opt=attest:%s=%s", optAttestType, optAttestAttrs))
   383  		} else {
   384  			return "", nil, false, "", nil, nil, fmt.Errorf("attestation type not specified")
   385  		}
   386  	}
   387  
   388  	for _, s := range strutil.DedupeStrSlice(options.SSH) {
   389  		buildctlArgs = append(buildctlArgs, "--ssh="+s)
   390  	}
   391  
   392  	for _, s := range strutil.DedupeStrSlice(options.CacheFrom) {
   393  		if !strings.Contains(s, "type=") {
   394  			s = "type=registry,ref=" + s
   395  		}
   396  		buildctlArgs = append(buildctlArgs, "--import-cache="+s)
   397  	}
   398  
   399  	for _, s := range strutil.DedupeStrSlice(options.CacheTo) {
   400  		if !strings.Contains(s, "type=") {
   401  			s = "type=registry,ref=" + s
   402  		}
   403  		buildctlArgs = append(buildctlArgs, "--export-cache="+s)
   404  	}
   405  
   406  	if !options.Rm {
   407  		log.L.Warn("ignoring deprecated flag: '--rm=false'")
   408  	}
   409  
   410  	if options.IidFile != "" {
   411  		file, err := os.CreateTemp("", "buildkit-meta-*")
   412  		if err != nil {
   413  			return "", nil, false, "", nil, cleanup, err
   414  		}
   415  		defer file.Close()
   416  		metaFile = file.Name()
   417  		buildctlArgs = append(buildctlArgs, "--metadata-file="+metaFile)
   418  	}
   419  
   420  	if options.NetworkMode != "" {
   421  		switch options.NetworkMode {
   422  		case "none":
   423  			buildctlArgs = append(buildctlArgs, "--opt=force-network-mode="+options.NetworkMode)
   424  		case "host":
   425  			buildctlArgs = append(buildctlArgs, "--opt=force-network-mode="+options.NetworkMode, "--allow=network.host", "--allow=security.insecure")
   426  		case "", "default":
   427  		default:
   428  			log.L.Debugf("ignoring network build arg %s", options.NetworkMode)
   429  		}
   430  	}
   431  
   432  	return buildctlBinary, buildctlArgs, needsLoading, metaFile, tags, cleanup, nil
   433  }
   434  
   435  func getDigestFromMetaFile(path string) (string, error) {
   436  	data, err := os.ReadFile(path)
   437  	if err != nil {
   438  		return "", err
   439  	}
   440  	defer os.Remove(path)
   441  
   442  	metadata := map[string]json.RawMessage{}
   443  	if err := json.Unmarshal(data, &metadata); err != nil {
   444  		log.L.WithError(err).Errorf("failed to unmarshal metadata file %s", path)
   445  		return "", err
   446  	}
   447  	digestRaw, ok := metadata["containerimage.digest"]
   448  	if !ok {
   449  		return "", errors.New("failed to find containerimage.digest in metadata file")
   450  	}
   451  	var digest string
   452  	if err := json.Unmarshal(digestRaw, &digest); err != nil {
   453  		log.L.WithError(err).Errorf("failed to unmarshal digset")
   454  		return "", err
   455  	}
   456  	return digest, nil
   457  }
   458  
   459  func isMatchingRuntimePlatform(platform string, parser PlatformParser) bool {
   460  	p, err := parser.Parse(platform)
   461  	if err != nil {
   462  		return false
   463  	}
   464  	d := parser.DefaultSpec()
   465  
   466  	if p.OS == d.OS && p.Architecture == d.Architecture && (p.Variant == "" || p.Variant == d.Variant) {
   467  		return true
   468  	}
   469  
   470  	return false
   471  }
   472  
   473  func isBuildPlatformDefault(platform []string, parser PlatformParser) bool {
   474  	if len(platform) == 0 {
   475  		return true
   476  	} else if len(platform) == 1 {
   477  		return isMatchingRuntimePlatform(platform[0], parser)
   478  	}
   479  	return false
   480  }
   481  
   482  func isImageSharable(buildkitHost, namespace, uuid, snapshotter string, platform []string) (bool, error) {
   483  	labels, err := buildkitutil.GetWorkerLabels(buildkitHost)
   484  	if err != nil {
   485  		return false, err
   486  	}
   487  	log.L.Debugf("worker labels: %+v", labels)
   488  	executor, ok := labels["org.mobyproject.buildkit.worker.executor"]
   489  	if !ok {
   490  		return false, nil
   491  	}
   492  	containerdUUID, ok := labels["org.mobyproject.buildkit.worker.containerd.uuid"]
   493  	if !ok {
   494  		return false, nil
   495  	}
   496  	containerdNamespace, ok := labels["org.mobyproject.buildkit.worker.containerd.namespace"]
   497  	if !ok {
   498  		return false, nil
   499  	}
   500  	workerSnapshotter, ok := labels["org.mobyproject.buildkit.worker.snapshotter"]
   501  	if !ok {
   502  		return false, nil
   503  	}
   504  	// NOTE: It's possible that BuildKit doesn't download the base image of non-default platform (e.g. when the provided
   505  	//       Dockerfile doesn't contain instructions require base images like RUN) even if `--output type=image,unpack=true`
   506  	//       is passed to BuildKit. Thus, we need to use `type=docker` or `type=oci` when nerdctl builds non-default platform
   507  	//       image using `platform` option.
   508  	parser := new(platformParser)
   509  	return executor == "containerd" && containerdUUID == uuid && containerdNamespace == namespace && workerSnapshotter == snapshotter && isBuildPlatformDefault(platform, parser), nil
   510  }
   511  
   512  func parseContextNames(values []string) (map[string]string, error) {
   513  	if len(values) == 0 {
   514  		return nil, nil
   515  	}
   516  	result := make(map[string]string, len(values))
   517  	for _, value := range values {
   518  		kv := strings.SplitN(value, "=", 2)
   519  		if len(kv) != 2 {
   520  			return nil, fmt.Errorf("invalid context value: %s, expected key=value", value)
   521  		}
   522  		result[kv[0]] = kv[1]
   523  	}
   524  	return result, nil
   525  }