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 }