github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/tiltfile/docker.go (about) 1 package tiltfile 2 3 import ( 4 "bytes" 5 "fmt" 6 "os" 7 "path/filepath" 8 "sort" 9 "strings" 10 11 "github.com/distribution/reference" 12 "github.com/moby/buildkit/frontend/dockerfile/dockerignore" 13 "github.com/pkg/errors" 14 "go.starlark.net/starlark" 15 16 "github.com/tilt-dev/tilt/internal/container" 17 "github.com/tilt-dev/tilt/internal/dockerfile" 18 "github.com/tilt-dev/tilt/internal/ospath" 19 "github.com/tilt-dev/tilt/internal/sliceutils" 20 "github.com/tilt-dev/tilt/internal/tiltfile/io" 21 "github.com/tilt-dev/tilt/internal/tiltfile/starkit" 22 "github.com/tilt-dev/tilt/internal/tiltfile/value" 23 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 24 "github.com/tilt-dev/tilt/pkg/model" 25 ) 26 27 const dockerPlatformEnv = "DOCKER_DEFAULT_PLATFORM" 28 29 var cacheObsoleteWarning = "docker_build(cache=...) is obsolete, and currently a no-op.\n" + 30 "You should switch to live_update to optimize your builds." 31 32 type dockerImage struct { 33 buildType dockerImageBuildType 34 configurationRef container.RefSelector 35 matchInEnvVars bool 36 sshSpecs []string 37 secretSpecs []string 38 ignores []string 39 onlys []string 40 entrypoint model.Cmd // optional: if specified, we override the image entrypoint/k8s command with this 41 targetStage string // optional: if specified, we build a particular target in the dockerfile 42 network string 43 extraTags []string // Extra tags added at build-time. 44 cacheFrom []string 45 pullParent bool 46 platform string 47 48 // Overrides the container args. Used as an escape hatch in case people want the old entrypoint behavior. 49 // See discussion here: 50 // https://github.com/tilt-dev/tilt/pull/2933 51 overrideArgs *v1alpha1.ImageMapOverrideArgs 52 53 dbDockerfilePath string 54 dbDockerfile dockerfile.Dockerfile 55 56 // dbBuildPath may be empty if the user is building from a URL 57 dbBuildPath string 58 dbBuildArgs []string 59 customCommand model.Cmd 60 customDeps []string 61 customTag string 62 customImgDeps []reference.Named 63 64 // Whether this has been matched up yet to a deploy resource. 65 matched bool 66 67 imageMapDeps []string 68 69 // Only applicable to custom_build 70 disablePush bool 71 skipsLocalDocker bool 72 outputsImageRefTo string 73 74 liveUpdate v1alpha1.LiveUpdateSpec 75 76 // TODO(milas): we should have a better way of passing the Tiltfile path around during resource assembly 77 tiltfilePath string 78 79 dockerComposeService string 80 dockerComposeLocalVolumePaths []string 81 82 extraHosts []string 83 } 84 85 func (d *dockerImage) ID() model.TargetID { 86 return model.ImageID(d.configurationRef) 87 } 88 89 func (d *dockerImage) ImageMapName() string { 90 return string(model.ImageID(d.configurationRef).Name) 91 } 92 93 type dockerImageBuildType int 94 95 const ( 96 UnknownBuild dockerImageBuildType = iota 97 DockerBuild 98 CustomBuild 99 DockerComposeBuild 100 ) 101 102 func (d *dockerImage) Type() dockerImageBuildType { 103 return d.buildType 104 } 105 106 func (s *tiltfileState) dockerBuild(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { 107 var dockerRef, targetStage string 108 contextVal := value.NewLocalPathUnpacker(thread) 109 dockerfilePathVal := value.NewLocalPathUnpacker(thread) 110 var dockerfileContentsVal, 111 cacheVal, 112 liveUpdateVal, 113 ignoreVal, 114 onlyVal, 115 entrypoint starlark.Value 116 var buildArgs value.StringStringMap 117 var network, platform value.Stringable 118 var ssh, secret, extraTags, cacheFrom, extraHosts value.StringOrStringList 119 var matchInEnvVars, pullParent bool 120 var overrideArgsVal starlark.Sequence 121 if err := s.unpackArgs(fn.Name(), args, kwargs, 122 "ref", &dockerRef, 123 "context", &contextVal, 124 "build_args?", &buildArgs, 125 "dockerfile??", &dockerfilePathVal, 126 "dockerfile_contents?", &dockerfileContentsVal, 127 "cache?", &cacheVal, 128 "live_update?", &liveUpdateVal, 129 "match_in_env_vars?", &matchInEnvVars, 130 "ignore?", &ignoreVal, 131 "only?", &onlyVal, 132 "entrypoint?", &entrypoint, 133 "container_args?", &overrideArgsVal, 134 "target?", &targetStage, 135 "ssh?", &ssh, 136 "secret?", &secret, 137 "network?", &network, 138 "extra_tag?", &extraTags, 139 "cache_from?", &cacheFrom, 140 "pull?", &pullParent, 141 "platform?", &platform, 142 "extra_hosts?", &extraHosts, 143 ); err != nil { 144 return nil, err 145 } 146 147 ref, err := container.ParseNamed(dockerRef) 148 if err != nil { 149 return nil, fmt.Errorf("Argument 1 (ref): can't parse %q: %v", dockerRef, err) 150 } 151 152 context := contextVal.Value 153 dockerfilePath := filepath.Join(context, "Dockerfile") 154 var dockerfileContents string 155 if dockerfileContentsVal != nil && dockerfilePathVal.IsSet { 156 return nil, fmt.Errorf("Cannot specify both dockerfile and dockerfile_contents keyword arguments") 157 } 158 if dockerfileContentsVal != nil { 159 switch v := dockerfileContentsVal.(type) { 160 case io.Blob: 161 dockerfileContents = v.Text 162 case starlark.String: 163 dockerfileContents = v.GoString() 164 default: 165 return nil, fmt.Errorf("Argument (dockerfile_contents): must be string or blob.") 166 } 167 } else if dockerfilePathVal.IsSet { 168 dockerfilePath = dockerfilePathVal.Value 169 bs, err := io.ReadFile(thread, dockerfilePath) 170 if err != nil { 171 return nil, errors.Wrap(err, "error reading dockerfile") 172 } 173 dockerfileContents = string(bs) 174 } else { 175 bs, err := io.ReadFile(thread, dockerfilePath) 176 if err != nil { 177 return nil, errors.Wrapf(err, "error reading dockerfile") 178 } 179 dockerfileContents = string(bs) 180 } 181 182 if cacheVal != nil { 183 s.logger.Warnf("%s", cacheObsoleteWarning) 184 } 185 186 liveUpdate, err := s.liveUpdateFromSteps(thread, liveUpdateVal) 187 if err != nil { 188 return nil, errors.Wrap(err, "live_update") 189 } 190 191 ignores, err := parseValuesToStrings(ignoreVal, "ignore") 192 if err != nil { 193 return nil, err 194 } 195 196 onlys, err := s.parseOnly(onlyVal) 197 if err != nil { 198 return nil, err 199 } 200 201 entrypointCmd, err := value.ValueToUnixCmd(thread, entrypoint, nil, nil) 202 if err != nil { 203 return nil, err 204 } 205 206 var overrideArgs *v1alpha1.ImageMapOverrideArgs 207 if overrideArgsVal != nil { 208 args, err := value.SequenceToStringSlice(overrideArgsVal) 209 if err != nil { 210 return nil, fmt.Errorf("Argument 'container_args': %v", err) 211 } 212 overrideArgs = &v1alpha1.ImageMapOverrideArgs{Args: args} 213 } 214 215 for _, extraTag := range extraTags.Values { 216 _, err := container.ParseNamed(extraTag) 217 if err != nil { 218 return nil, fmt.Errorf("Argument extra_tag=%q not a valid image reference: %v", extraTag, err) 219 } 220 } 221 222 if platform.Value == "" { 223 // for compatibility with Docker CLI, support the env var fallback 224 // see https://docs.docker.com/engine/reference/commandline/cli/#environment-variables 225 platform.Value = os.Getenv(dockerPlatformEnv) 226 } 227 228 buildArgsList := []string{} 229 for k, v := range buildArgs.AsMap() { 230 if v == "" { 231 buildArgsList = append(buildArgsList, k) 232 } else { 233 buildArgsList = append(buildArgsList, fmt.Sprintf("%s=%s", k, v)) 234 } 235 } 236 sort.Strings(buildArgsList) 237 238 r := &dockerImage{ 239 buildType: DockerBuild, 240 dbDockerfilePath: dockerfilePath, 241 dbDockerfile: dockerfile.Dockerfile(dockerfileContents), 242 dbBuildPath: context, 243 configurationRef: container.NewRefSelector(ref), 244 dbBuildArgs: buildArgsList, 245 liveUpdate: liveUpdate, 246 matchInEnvVars: matchInEnvVars, 247 sshSpecs: ssh.Values, 248 secretSpecs: secret.Values, 249 ignores: ignores, 250 onlys: onlys, 251 entrypoint: entrypointCmd, 252 overrideArgs: overrideArgs, 253 targetStage: targetStage, 254 network: network.Value, 255 extraTags: extraTags.Values, 256 cacheFrom: cacheFrom.Values, 257 pullParent: pullParent, 258 platform: platform.Value, 259 tiltfilePath: starkit.CurrentExecPath(thread), 260 extraHosts: extraHosts.Values, 261 } 262 err = s.buildIndex.addImage(r) 263 if err != nil { 264 return nil, err 265 } 266 267 return starlark.None, nil 268 } 269 270 func (s *tiltfileState) parseOnly(val starlark.Value) ([]string, error) { 271 paths, err := parseValuesToStrings(val, "only") 272 if err != nil { 273 return nil, err 274 } 275 276 for _, p := range paths { 277 // We want to forbid file globs due to these issues: 278 // https://github.com/tilt-dev/tilt/issues/1982 279 // https://github.com/moby/moby/issues/30018 280 if strings.Contains(p, "*") { 281 return nil, fmt.Errorf("'only' does not support '*' file globs. Must be a real path: %s", p) 282 } 283 } 284 return paths, nil 285 } 286 287 func (s *tiltfileState) customBuild(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { 288 var dockerRef string 289 var commandVal, commandBat, commandBatVal starlark.Value 290 deps := value.NewLocalPathListUnpacker(thread) 291 var tag string 292 var disablePush bool 293 var liveUpdateVal, ignoreVal starlark.Value 294 var matchInEnvVars bool 295 var entrypoint starlark.Value 296 var overrideArgsVal starlark.Sequence 297 var skipsLocalDocker bool 298 var imageDeps value.ImageList 299 var env value.StringStringMap 300 var dir starlark.Value 301 outputsImageRefTo := value.NewLocalPathUnpacker(thread) 302 303 err := s.unpackArgs(fn.Name(), args, kwargs, 304 "ref", &dockerRef, 305 "command", &commandVal, 306 "deps", &deps, 307 "tag?", &tag, 308 "disable_push?", &disablePush, 309 "skips_local_docker?", &skipsLocalDocker, 310 "live_update?", &liveUpdateVal, 311 "match_in_env_vars?", &matchInEnvVars, 312 "ignore?", &ignoreVal, 313 "entrypoint?", &entrypoint, 314 "container_args?", &overrideArgsVal, 315 "command_bat_val", &commandBatVal, 316 "outputs_image_ref_to", &outputsImageRefTo, 317 318 // This is a crappy fix for https://github.com/tilt-dev/tilt/issues/4061 319 // so that we don't break things. 320 "command_bat", &commandBat, 321 322 "image_deps", &imageDeps, 323 "env?", &env, 324 "dir?", &dir, 325 ) 326 if err != nil { 327 return nil, err 328 } 329 330 ref, err := container.ParseNamed(dockerRef) 331 if err != nil { 332 return nil, fmt.Errorf("Argument 1 (ref): can't parse %q: %v", dockerRef, err) 333 } 334 335 liveUpdate, err := s.liveUpdateFromSteps(thread, liveUpdateVal) 336 if err != nil { 337 return nil, errors.Wrap(err, "live_update") 338 } 339 340 ignores, err := parseValuesToStrings(ignoreVal, "ignore") 341 if err != nil { 342 return nil, err 343 } 344 345 entrypointCmd, err := value.ValueToUnixCmd(thread, entrypoint, nil, nil) 346 if err != nil { 347 return nil, err 348 } 349 350 var overrideArgs *v1alpha1.ImageMapOverrideArgs 351 if overrideArgsVal != nil { 352 args, err := value.SequenceToStringSlice(overrideArgsVal) 353 if err != nil { 354 return nil, fmt.Errorf("Argument 'container_args': %v", err) 355 } 356 overrideArgs = &v1alpha1.ImageMapOverrideArgs{Args: args} 357 } 358 359 if commandBat == nil { 360 commandBat = commandBatVal 361 } 362 363 command, err := value.ValueGroupToCmdHelper(thread, commandVal, commandBat, dir, env) 364 if err != nil { 365 return nil, fmt.Errorf("Argument 2 (command): %v", err) 366 } else if command.Empty() { 367 return nil, fmt.Errorf("Argument 2 (command) can't be empty") 368 } 369 370 if tag != "" && outputsImageRefTo.Value != "" { 371 return nil, fmt.Errorf("Cannot specify both tag= and outputs_image_ref_to=") 372 } 373 374 img := &dockerImage{ 375 buildType: CustomBuild, 376 configurationRef: container.NewRefSelector(ref), 377 customCommand: command, 378 customDeps: deps.Value, 379 customTag: tag, 380 customImgDeps: []reference.Named(imageDeps), 381 disablePush: disablePush, 382 skipsLocalDocker: skipsLocalDocker, 383 liveUpdate: liveUpdate, 384 matchInEnvVars: matchInEnvVars, 385 ignores: ignores, 386 entrypoint: entrypointCmd, 387 overrideArgs: overrideArgs, 388 outputsImageRefTo: outputsImageRefTo.Value, 389 tiltfilePath: starkit.CurrentExecPath(thread), 390 } 391 392 err = s.buildIndex.addImage(img) 393 if err != nil { 394 return nil, err 395 } 396 397 return &customBuild{s: s, img: img}, nil 398 } 399 400 type customBuild struct { 401 s *tiltfileState 402 img *dockerImage 403 } 404 405 var _ starlark.Value = &customBuild{} 406 407 func (b *customBuild) String() string { 408 return fmt.Sprintf("custom_build(%q)", b.img.configurationRef.String()) 409 } 410 411 func (b *customBuild) Type() string { 412 return "custom_build" 413 } 414 415 func (b *customBuild) Freeze() {} 416 417 func (b *customBuild) Truth() starlark.Bool { 418 return true 419 } 420 421 func (b *customBuild) Hash() (uint32, error) { 422 return 0, fmt.Errorf("unhashable type: custom_build") 423 } 424 425 func (b *customBuild) AttrNames() []string { 426 return []string{} 427 } 428 429 func parseValuesToStrings(value starlark.Value, param string) ([]string, error) { 430 431 tempIgnores := starlarkValueOrSequenceToSlice(value) 432 var ignores []string 433 for _, v := range tempIgnores { 434 switch val := v.(type) { 435 case starlark.String: // for singular string 436 goString := val.GoString() 437 if strings.Contains(goString, "\n") { 438 return nil, fmt.Errorf(param+" cannot contain newlines; found "+param+": %q", goString) 439 } 440 ignores = append(ignores, val.GoString()) 441 default: 442 return nil, fmt.Errorf(param+" must be a string or a sequence of strings; found a %T", val) 443 } 444 } 445 return ignores, nil 446 447 } 448 449 func isGitRepoBase(path string) bool { 450 return ospath.IsDir(filepath.Join(path, ".git")) 451 } 452 453 func repoIgnoresForPaths(paths []string) []v1alpha1.IgnoreDef { 454 var result []v1alpha1.IgnoreDef 455 repoSet := map[string]bool{} 456 457 for _, path := range paths { 458 isRepoBase := isGitRepoBase(path) 459 if !isRepoBase || repoSet[path] { 460 continue 461 } 462 463 repoSet[path] = true 464 result = append(result, v1alpha1.IgnoreDef{ 465 BasePath: filepath.Join(path, ".git"), 466 }) 467 } 468 469 return result 470 } 471 472 func (s *tiltfileState) repoIgnoresForImage(image *dockerImage) []v1alpha1.IgnoreDef { 473 var paths []string 474 paths = append(paths, image.dbDockerfilePath) 475 if image.dbBuildPath != "" { 476 paths = append(paths, image.dbBuildPath) 477 } 478 paths = append(paths, image.customDeps...) 479 480 return repoIgnoresForPaths(paths) 481 } 482 483 func (s *tiltfileState) defaultRegistry(t *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { 484 if !container.IsEmptyRegistry(s.defaultReg) { 485 return starlark.None, errors.New("default registry already defined") 486 } 487 488 var host, hostFromCluster, singleName string 489 if err := s.unpackArgs(fn.Name(), args, kwargs, 490 "host", &host, 491 "host_from_cluster?", &hostFromCluster, 492 "single_name?", &singleName); err != nil { 493 return nil, err 494 } 495 496 reg := &v1alpha1.RegistryHosting{ 497 Host: host, 498 HostFromContainerRuntime: hostFromCluster, 499 SingleName: singleName, 500 } 501 502 ctx, err := starkit.ContextFromThread(t) 503 if err != nil { 504 return starlark.None, err 505 } 506 507 if err := reg.Validate(ctx); err != nil { 508 return starlark.None, errors.Wrapf(err.ToAggregate(), "validating defaultRegistry") 509 } 510 511 reg.SingleName = singleName 512 513 s.defaultReg = reg 514 515 return starlark.None, nil 516 } 517 518 func (s *tiltfileState) dockerignoresFromPathsAndContextFilters(source string, paths []string, ignorePatterns []string, onlys []string, dbDockerfilePath string) ([]model.Dockerignore, error) { 519 var result []model.Dockerignore 520 dupeSet := map[string]bool{} 521 onlyPatterns := onlysToDockerignorePatterns(onlys) 522 523 for _, path := range paths { 524 if path == "" || dupeSet[path] { 525 continue 526 } 527 dupeSet[path] = true 528 529 if !ospath.IsDir(path) { 530 continue 531 } 532 533 if len(ignorePatterns) != 0 { 534 result = append(result, model.Dockerignore{ 535 LocalPath: path, 536 Source: source + " ignores=", 537 Patterns: ignorePatterns, 538 }) 539 } 540 541 if len(onlyPatterns) != 0 { 542 result = append(result, model.Dockerignore{ 543 LocalPath: path, 544 Source: source + " only=", 545 Patterns: onlyPatterns, 546 }) 547 } 548 549 diFile := filepath.Join(path, ".dockerignore") 550 if dbDockerfilePath != "" { 551 customDiFile := dbDockerfilePath + ".dockerignore" 552 _, err := os.Stat(customDiFile) 553 if !os.IsNotExist(err) { 554 diFile = customDiFile 555 } 556 } 557 558 s.postExecReadFiles = sliceutils.AppendWithoutDupes(s.postExecReadFiles, diFile) 559 560 contents, err := os.ReadFile(diFile) 561 if err != nil { 562 if os.IsNotExist(err) { 563 continue 564 } 565 return nil, err 566 } 567 568 patterns, err := dockerignore.ReadAll(bytes.NewBuffer(contents)) 569 if err != nil { 570 return nil, err 571 } 572 573 result = append(result, model.Dockerignore{ 574 LocalPath: path, 575 Source: diFile, 576 Patterns: patterns, 577 }) 578 } 579 580 return result, nil 581 } 582 583 func onlysToDockerignorePatterns(onlys []string) []string { 584 if len(onlys) == 0 { 585 return nil 586 } 587 588 result := []string{"**"} 589 590 for _, only := range onlys { 591 result = append(result, fmt.Sprintf("!%s", only)) 592 } 593 594 return result 595 } 596 597 func (s *tiltfileState) dockerignoresForImage(image *dockerImage) ([]model.Dockerignore, error) { 598 var paths []string 599 var source string 600 ref := image.configurationRef.RefFamiliarString() 601 switch image.Type() { 602 case DockerBuild: 603 if image.dbBuildPath != "" { 604 paths = append(paths, image.dbBuildPath) 605 } 606 source = fmt.Sprintf("docker_build(%q)", ref) 607 case CustomBuild: 608 paths = append(paths, image.customDeps...) 609 source = fmt.Sprintf("custom_build(%q)", ref) 610 case DockerComposeBuild: 611 if image.dbBuildPath != "" { 612 paths = append(paths, image.dbBuildPath) 613 } 614 source = fmt.Sprintf("docker_compose(%q)", ref) 615 } 616 return s.dockerignoresFromPathsAndContextFilters( 617 source, 618 paths, image.ignores, image.onlys, image.dbDockerfilePath) 619 } 620 621 // Filter out all images that are suppressed. 622 func filterUnmatchedImages(us model.UpdateSettings, images []*dockerImage) []*dockerImage { 623 result := make([]*dockerImage, 0, len(images)) 624 for _, image := range images { 625 name := container.FamiliarString(image.configurationRef) 626 627 ok := true 628 for _, suppressed := range us.SuppressUnusedImageWarnings { 629 if suppressed == "*" { 630 ok = false 631 break 632 } 633 634 if suppressed == name { 635 ok = false 636 break 637 } 638 } 639 640 if ok { 641 result = append(result, image) 642 } 643 } 644 return result 645 }