github.com/webwurst/docker@v1.7.0/builder/internals.go (about) 1 package builder 2 3 // internals for handling commands. Covers many areas and a lot of 4 // non-contiguous functionality. Please read the comments. 5 6 import ( 7 "crypto/sha256" 8 "encoding/hex" 9 "fmt" 10 "io" 11 "io/ioutil" 12 "net/http" 13 "net/url" 14 "os" 15 "path" 16 "path/filepath" 17 "sort" 18 "strings" 19 "syscall" 20 "time" 21 22 "github.com/Sirupsen/logrus" 23 "github.com/docker/docker/builder/parser" 24 "github.com/docker/docker/daemon" 25 "github.com/docker/docker/graph" 26 imagepkg "github.com/docker/docker/image" 27 "github.com/docker/docker/pkg/archive" 28 "github.com/docker/docker/pkg/chrootarchive" 29 "github.com/docker/docker/pkg/httputils" 30 "github.com/docker/docker/pkg/ioutils" 31 "github.com/docker/docker/pkg/jsonmessage" 32 "github.com/docker/docker/pkg/parsers" 33 "github.com/docker/docker/pkg/progressreader" 34 "github.com/docker/docker/pkg/stringid" 35 "github.com/docker/docker/pkg/system" 36 "github.com/docker/docker/pkg/tarsum" 37 "github.com/docker/docker/pkg/urlutil" 38 "github.com/docker/docker/registry" 39 "github.com/docker/docker/runconfig" 40 ) 41 42 func (b *Builder) readContext(context io.Reader) error { 43 tmpdirPath, err := ioutil.TempDir("", "docker-build") 44 if err != nil { 45 return err 46 } 47 48 decompressedStream, err := archive.DecompressStream(context) 49 if err != nil { 50 return err 51 } 52 53 if b.context, err = tarsum.NewTarSum(decompressedStream, true, tarsum.Version0); err != nil { 54 return err 55 } 56 57 if err := chrootarchive.Untar(b.context, tmpdirPath, nil); err != nil { 58 return err 59 } 60 61 b.contextPath = tmpdirPath 62 return nil 63 } 64 65 func (b *Builder) commit(id string, autoCmd *runconfig.Command, comment string) error { 66 if b.disableCommit { 67 return nil 68 } 69 if b.image == "" && !b.noBaseImage { 70 return fmt.Errorf("Please provide a source image with `from` prior to commit") 71 } 72 b.Config.Image = b.image 73 if id == "" { 74 cmd := b.Config.Cmd 75 b.Config.Cmd = runconfig.NewCommand("/bin/sh", "-c", "#(nop) "+comment) 76 defer func(cmd *runconfig.Command) { b.Config.Cmd = cmd }(cmd) 77 78 hit, err := b.probeCache() 79 if err != nil { 80 return err 81 } 82 if hit { 83 return nil 84 } 85 86 container, err := b.create() 87 if err != nil { 88 return err 89 } 90 id = container.ID 91 92 if err := container.Mount(); err != nil { 93 return err 94 } 95 defer container.Unmount() 96 } 97 container, err := b.Daemon.Get(id) 98 if err != nil { 99 return err 100 } 101 102 // Note: Actually copy the struct 103 autoConfig := *b.Config 104 autoConfig.Cmd = autoCmd 105 106 // Commit the container 107 image, err := b.Daemon.Commit(container, "", "", "", b.maintainer, true, &autoConfig) 108 if err != nil { 109 return err 110 } 111 b.image = image.ID 112 return nil 113 } 114 115 type copyInfo struct { 116 origPath string 117 destPath string 118 hash string 119 decompress bool 120 tmpDir string 121 } 122 123 func (b *Builder) runContextCommand(args []string, allowRemote bool, allowDecompression bool, cmdName string) error { 124 if b.context == nil { 125 return fmt.Errorf("No context given. Impossible to use %s", cmdName) 126 } 127 128 if len(args) < 2 { 129 return fmt.Errorf("Invalid %s format - at least two arguments required", cmdName) 130 } 131 132 dest := args[len(args)-1] // last one is always the dest 133 134 copyInfos := []*copyInfo{} 135 136 b.Config.Image = b.image 137 138 defer func() { 139 for _, ci := range copyInfos { 140 if ci.tmpDir != "" { 141 os.RemoveAll(ci.tmpDir) 142 } 143 } 144 }() 145 146 // Loop through each src file and calculate the info we need to 147 // do the copy (e.g. hash value if cached). Don't actually do 148 // the copy until we've looked at all src files 149 for _, orig := range args[0 : len(args)-1] { 150 if err := calcCopyInfo( 151 b, 152 cmdName, 153 ©Infos, 154 orig, 155 dest, 156 allowRemote, 157 allowDecompression, 158 true, 159 ); err != nil { 160 return err 161 } 162 } 163 164 if len(copyInfos) == 0 { 165 return fmt.Errorf("No source files were specified") 166 } 167 168 if len(copyInfos) > 1 && !strings.HasSuffix(dest, "/") { 169 return fmt.Errorf("When using %s with more than one source file, the destination must be a directory and end with a /", cmdName) 170 } 171 172 // For backwards compat, if there's just one CI then use it as the 173 // cache look-up string, otherwise hash 'em all into one 174 var srcHash string 175 var origPaths string 176 177 if len(copyInfos) == 1 { 178 srcHash = copyInfos[0].hash 179 origPaths = copyInfos[0].origPath 180 } else { 181 var hashs []string 182 var origs []string 183 for _, ci := range copyInfos { 184 hashs = append(hashs, ci.hash) 185 origs = append(origs, ci.origPath) 186 } 187 hasher := sha256.New() 188 hasher.Write([]byte(strings.Join(hashs, ","))) 189 srcHash = "multi:" + hex.EncodeToString(hasher.Sum(nil)) 190 origPaths = strings.Join(origs, " ") 191 } 192 193 cmd := b.Config.Cmd 194 b.Config.Cmd = runconfig.NewCommand("/bin/sh", "-c", fmt.Sprintf("#(nop) %s %s in %s", cmdName, srcHash, dest)) 195 defer func(cmd *runconfig.Command) { b.Config.Cmd = cmd }(cmd) 196 197 hit, err := b.probeCache() 198 if err != nil { 199 return err 200 } 201 202 if hit { 203 return nil 204 } 205 206 container, _, err := b.Daemon.Create(b.Config, nil, "") 207 if err != nil { 208 return err 209 } 210 b.TmpContainers[container.ID] = struct{}{} 211 212 if err := container.Mount(); err != nil { 213 return err 214 } 215 defer container.Unmount() 216 217 for _, ci := range copyInfos { 218 if err := b.addContext(container, ci.origPath, ci.destPath, ci.decompress); err != nil { 219 return err 220 } 221 } 222 223 if err := b.commit(container.ID, cmd, fmt.Sprintf("%s %s in %s", cmdName, origPaths, dest)); err != nil { 224 return err 225 } 226 return nil 227 } 228 229 func calcCopyInfo(b *Builder, cmdName string, cInfos *[]*copyInfo, origPath string, destPath string, allowRemote bool, allowDecompression bool, allowWildcards bool) error { 230 231 if origPath != "" && origPath[0] == '/' && len(origPath) > 1 { 232 origPath = origPath[1:] 233 } 234 origPath = strings.TrimPrefix(origPath, "./") 235 236 // Twiddle the destPath when its a relative path - meaning, make it 237 // relative to the WORKINGDIR 238 if !filepath.IsAbs(destPath) { 239 hasSlash := strings.HasSuffix(destPath, "/") 240 destPath = filepath.Join("/", b.Config.WorkingDir, destPath) 241 242 // Make sure we preserve any trailing slash 243 if hasSlash { 244 destPath += "/" 245 } 246 } 247 248 // In the remote/URL case, download it and gen its hashcode 249 if urlutil.IsURL(origPath) { 250 if !allowRemote { 251 return fmt.Errorf("Source can't be a URL for %s", cmdName) 252 } 253 254 ci := copyInfo{} 255 ci.origPath = origPath 256 ci.hash = origPath // default to this but can change 257 ci.destPath = destPath 258 ci.decompress = false 259 *cInfos = append(*cInfos, &ci) 260 261 // Initiate the download 262 resp, err := httputils.Download(ci.origPath) 263 if err != nil { 264 return err 265 } 266 267 // Create a tmp dir 268 tmpDirName, err := ioutil.TempDir(b.contextPath, "docker-remote") 269 if err != nil { 270 return err 271 } 272 ci.tmpDir = tmpDirName 273 274 // Create a tmp file within our tmp dir 275 tmpFileName := path.Join(tmpDirName, "tmp") 276 tmpFile, err := os.OpenFile(tmpFileName, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) 277 if err != nil { 278 return err 279 } 280 281 // Download and dump result to tmp file 282 if _, err := io.Copy(tmpFile, progressreader.New(progressreader.Config{ 283 In: resp.Body, 284 Out: b.OutOld, 285 Formatter: b.StreamFormatter, 286 Size: int(resp.ContentLength), 287 NewLines: true, 288 ID: "", 289 Action: "Downloading", 290 })); err != nil { 291 tmpFile.Close() 292 return err 293 } 294 fmt.Fprintf(b.OutStream, "\n") 295 tmpFile.Close() 296 297 // Set the mtime to the Last-Modified header value if present 298 // Otherwise just remove atime and mtime 299 times := make([]syscall.Timespec, 2) 300 301 lastMod := resp.Header.Get("Last-Modified") 302 if lastMod != "" { 303 mTime, err := http.ParseTime(lastMod) 304 // If we can't parse it then just let it default to 'zero' 305 // otherwise use the parsed time value 306 if err == nil { 307 times[1] = syscall.NsecToTimespec(mTime.UnixNano()) 308 } 309 } 310 311 if err := system.UtimesNano(tmpFileName, times); err != nil { 312 return err 313 } 314 315 ci.origPath = path.Join(filepath.Base(tmpDirName), filepath.Base(tmpFileName)) 316 317 // If the destination is a directory, figure out the filename. 318 if strings.HasSuffix(ci.destPath, "/") { 319 u, err := url.Parse(origPath) 320 if err != nil { 321 return err 322 } 323 path := u.Path 324 if strings.HasSuffix(path, "/") { 325 path = path[:len(path)-1] 326 } 327 parts := strings.Split(path, "/") 328 filename := parts[len(parts)-1] 329 if filename == "" { 330 return fmt.Errorf("cannot determine filename from url: %s", u) 331 } 332 ci.destPath = ci.destPath + filename 333 } 334 335 // Calc the checksum, even if we're using the cache 336 r, err := archive.Tar(tmpFileName, archive.Uncompressed) 337 if err != nil { 338 return err 339 } 340 tarSum, err := tarsum.NewTarSum(r, true, tarsum.Version0) 341 if err != nil { 342 return err 343 } 344 if _, err := io.Copy(ioutil.Discard, tarSum); err != nil { 345 return err 346 } 347 ci.hash = tarSum.Sum(nil) 348 r.Close() 349 350 return nil 351 } 352 353 // Deal with wildcards 354 if allowWildcards && ContainsWildcards(origPath) { 355 for _, fileInfo := range b.context.GetSums() { 356 if fileInfo.Name() == "" { 357 continue 358 } 359 match, _ := path.Match(origPath, fileInfo.Name()) 360 if !match { 361 continue 362 } 363 364 // Note we set allowWildcards to false in case the name has 365 // a * in it 366 calcCopyInfo(b, cmdName, cInfos, fileInfo.Name(), destPath, allowRemote, allowDecompression, false) 367 } 368 return nil 369 } 370 371 // Must be a dir or a file 372 373 if err := b.checkPathForAddition(origPath); err != nil { 374 return err 375 } 376 fi, _ := os.Stat(path.Join(b.contextPath, origPath)) 377 378 ci := copyInfo{} 379 ci.origPath = origPath 380 ci.hash = origPath 381 ci.destPath = destPath 382 ci.decompress = allowDecompression 383 *cInfos = append(*cInfos, &ci) 384 385 // Deal with the single file case 386 if !fi.IsDir() { 387 // This will match first file in sums of the archive 388 fis := b.context.GetSums().GetFile(ci.origPath) 389 if fis != nil { 390 ci.hash = "file:" + fis.Sum() 391 } 392 return nil 393 } 394 395 // Must be a dir 396 var subfiles []string 397 absOrigPath := path.Join(b.contextPath, ci.origPath) 398 399 // Add a trailing / to make sure we only pick up nested files under 400 // the dir and not sibling files of the dir that just happen to 401 // start with the same chars 402 if !strings.HasSuffix(absOrigPath, "/") { 403 absOrigPath += "/" 404 } 405 406 // Need path w/o / too to find matching dir w/o trailing / 407 absOrigPathNoSlash := absOrigPath[:len(absOrigPath)-1] 408 409 for _, fileInfo := range b.context.GetSums() { 410 absFile := path.Join(b.contextPath, fileInfo.Name()) 411 // Any file in the context that starts with the given path will be 412 // picked up and its hashcode used. However, we'll exclude the 413 // root dir itself. We do this for a coupel of reasons: 414 // 1 - ADD/COPY will not copy the dir itself, just its children 415 // so there's no reason to include it in the hash calc 416 // 2 - the metadata on the dir will change when any child file 417 // changes. This will lead to a miss in the cache check if that 418 // child file is in the .dockerignore list. 419 if strings.HasPrefix(absFile, absOrigPath) && absFile != absOrigPathNoSlash { 420 subfiles = append(subfiles, fileInfo.Sum()) 421 } 422 } 423 sort.Strings(subfiles) 424 hasher := sha256.New() 425 hasher.Write([]byte(strings.Join(subfiles, ","))) 426 ci.hash = "dir:" + hex.EncodeToString(hasher.Sum(nil)) 427 428 return nil 429 } 430 431 func ContainsWildcards(name string) bool { 432 for i := 0; i < len(name); i++ { 433 ch := name[i] 434 if ch == '\\' { 435 i++ 436 } else if ch == '*' || ch == '?' || ch == '[' { 437 return true 438 } 439 } 440 return false 441 } 442 443 func (b *Builder) pullImage(name string) (*imagepkg.Image, error) { 444 remote, tag := parsers.ParseRepositoryTag(name) 445 if tag == "" { 446 tag = "latest" 447 } 448 449 pullRegistryAuth := b.AuthConfig 450 if len(b.ConfigFile.AuthConfigs) > 0 { 451 // The request came with a full auth config file, we prefer to use that 452 repoInfo, err := b.Daemon.RegistryService.ResolveRepository(remote) 453 if err != nil { 454 return nil, err 455 } 456 resolvedAuth := registry.ResolveAuthConfig(b.ConfigFile, repoInfo.Index) 457 pullRegistryAuth = &resolvedAuth 458 } 459 460 imagePullConfig := &graph.ImagePullConfig{ 461 AuthConfig: pullRegistryAuth, 462 OutStream: ioutils.NopWriteCloser(b.OutOld), 463 } 464 465 if err := b.Daemon.Repositories().Pull(remote, tag, imagePullConfig); err != nil { 466 return nil, err 467 } 468 469 image, err := b.Daemon.Repositories().LookupImage(name) 470 if err != nil { 471 return nil, err 472 } 473 474 return image, nil 475 } 476 477 func (b *Builder) processImageFrom(img *imagepkg.Image) error { 478 b.image = img.ID 479 480 if img.Config != nil { 481 b.Config = img.Config 482 } 483 484 if len(b.Config.Env) == 0 { 485 b.Config.Env = append(b.Config.Env, "PATH="+daemon.DefaultPathEnv) 486 } 487 488 // Process ONBUILD triggers if they exist 489 if nTriggers := len(b.Config.OnBuild); nTriggers != 0 { 490 fmt.Fprintf(b.ErrStream, "# Executing %d build triggers\n", nTriggers) 491 } 492 493 // Copy the ONBUILD triggers, and remove them from the config, since the config will be committed. 494 onBuildTriggers := b.Config.OnBuild 495 b.Config.OnBuild = []string{} 496 497 // parse the ONBUILD triggers by invoking the parser 498 for stepN, step := range onBuildTriggers { 499 ast, err := parser.Parse(strings.NewReader(step)) 500 if err != nil { 501 return err 502 } 503 504 for i, n := range ast.Children { 505 switch strings.ToUpper(n.Value) { 506 case "ONBUILD": 507 return fmt.Errorf("Chaining ONBUILD via `ONBUILD ONBUILD` isn't allowed") 508 case "MAINTAINER", "FROM": 509 return fmt.Errorf("%s isn't allowed as an ONBUILD trigger", n.Value) 510 } 511 512 fmt.Fprintf(b.OutStream, "Trigger %d, %s\n", stepN, step) 513 514 if err := b.dispatch(i, n); err != nil { 515 return err 516 } 517 } 518 } 519 520 return nil 521 } 522 523 // probeCache checks to see if image-caching is enabled (`b.UtilizeCache`) 524 // and if so attempts to look up the current `b.image` and `b.Config` pair 525 // in the current server `b.Daemon`. If an image is found, probeCache returns 526 // `(true, nil)`. If no image is found, it returns `(false, nil)`. If there 527 // is any error, it returns `(false, err)`. 528 func (b *Builder) probeCache() (bool, error) { 529 if !b.UtilizeCache || b.cacheBusted { 530 return false, nil 531 } 532 533 cache, err := b.Daemon.ImageGetCached(b.image, b.Config) 534 if err != nil { 535 return false, err 536 } 537 if cache == nil { 538 logrus.Debugf("[BUILDER] Cache miss") 539 b.cacheBusted = true 540 return false, nil 541 } 542 543 fmt.Fprintf(b.OutStream, " ---> Using cache\n") 544 logrus.Debugf("[BUILDER] Use cached version") 545 b.image = cache.ID 546 return true, nil 547 } 548 549 func (b *Builder) create() (*daemon.Container, error) { 550 if b.image == "" && !b.noBaseImage { 551 return nil, fmt.Errorf("Please provide a source image with `from` prior to run") 552 } 553 b.Config.Image = b.image 554 555 hostConfig := &runconfig.HostConfig{ 556 CpuShares: b.cpuShares, 557 CpuPeriod: b.cpuPeriod, 558 CpuQuota: b.cpuQuota, 559 CpusetCpus: b.cpuSetCpus, 560 CpusetMems: b.cpuSetMems, 561 CgroupParent: b.cgroupParent, 562 Memory: b.memory, 563 MemorySwap: b.memorySwap, 564 NetworkMode: "bridge", 565 } 566 567 config := *b.Config 568 569 // Create the container 570 c, warnings, err := b.Daemon.Create(b.Config, hostConfig, "") 571 if err != nil { 572 return nil, err 573 } 574 for _, warning := range warnings { 575 fmt.Fprintf(b.OutStream, " ---> [Warning] %s\n", warning) 576 } 577 578 b.TmpContainers[c.ID] = struct{}{} 579 fmt.Fprintf(b.OutStream, " ---> Running in %s\n", stringid.TruncateID(c.ID)) 580 581 if config.Cmd.Len() > 0 { 582 // override the entry point that may have been picked up from the base image 583 s := config.Cmd.Slice() 584 c.Path = s[0] 585 c.Args = s[1:] 586 } else { 587 config.Cmd = runconfig.NewCommand() 588 } 589 590 return c, nil 591 } 592 593 func (b *Builder) run(c *daemon.Container) error { 594 var errCh chan error 595 if b.Verbose { 596 errCh = c.Attach(nil, b.OutStream, b.ErrStream) 597 } 598 599 //start the container 600 if err := c.Start(); err != nil { 601 return err 602 } 603 604 finished := make(chan struct{}) 605 defer close(finished) 606 go func() { 607 select { 608 case <-b.cancelled: 609 logrus.Debugln("Build cancelled, killing container:", c.ID) 610 c.Kill() 611 case <-finished: 612 } 613 }() 614 615 if b.Verbose { 616 // Block on reading output from container, stop on err or chan closed 617 if err := <-errCh; err != nil { 618 return err 619 } 620 } 621 622 // Wait for it to finish 623 if ret, _ := c.WaitStop(-1 * time.Second); ret != 0 { 624 return &jsonmessage.JSONError{ 625 Message: fmt.Sprintf("The command '%s' returned a non-zero code: %d", b.Config.Cmd.ToString(), ret), 626 Code: ret, 627 } 628 } 629 630 return nil 631 } 632 633 func (b *Builder) checkPathForAddition(orig string) error { 634 origPath := path.Join(b.contextPath, orig) 635 origPath, err := filepath.EvalSymlinks(origPath) 636 if err != nil { 637 if os.IsNotExist(err) { 638 return fmt.Errorf("%s: no such file or directory", orig) 639 } 640 return err 641 } 642 if !strings.HasPrefix(origPath, b.contextPath) { 643 return fmt.Errorf("Forbidden path outside the build context: %s (%s)", orig, origPath) 644 } 645 if _, err := os.Stat(origPath); err != nil { 646 if os.IsNotExist(err) { 647 return fmt.Errorf("%s: no such file or directory", orig) 648 } 649 return err 650 } 651 return nil 652 } 653 654 func (b *Builder) addContext(container *daemon.Container, orig, dest string, decompress bool) error { 655 var ( 656 err error 657 destExists = true 658 origPath = path.Join(b.contextPath, orig) 659 destPath string 660 ) 661 662 destPath, err = container.GetResourcePath(dest) 663 if err != nil { 664 return err 665 } 666 667 // Preserve the trailing '/' 668 if strings.HasSuffix(dest, "/") || dest == "." { 669 destPath = destPath + "/" 670 } 671 672 destStat, err := os.Stat(destPath) 673 if err != nil { 674 if !os.IsNotExist(err) { 675 return err 676 } 677 destExists = false 678 } 679 680 fi, err := os.Stat(origPath) 681 if err != nil { 682 if os.IsNotExist(err) { 683 return fmt.Errorf("%s: no such file or directory", orig) 684 } 685 return err 686 } 687 688 if fi.IsDir() { 689 return copyAsDirectory(origPath, destPath, destExists) 690 } 691 692 // If we are adding a remote file (or we've been told not to decompress), do not try to untar it 693 if decompress { 694 // First try to unpack the source as an archive 695 // to support the untar feature we need to clean up the path a little bit 696 // because tar is very forgiving. First we need to strip off the archive's 697 // filename from the path but this is only added if it does not end in / . 698 tarDest := destPath 699 if strings.HasSuffix(tarDest, "/") { 700 tarDest = filepath.Dir(destPath) 701 } 702 703 // try to successfully untar the orig 704 if err := chrootarchive.UntarPath(origPath, tarDest); err == nil { 705 return nil 706 } else if err != io.EOF { 707 logrus.Debugf("Couldn't untar %s to %s: %s", origPath, tarDest, err) 708 } 709 } 710 711 if err := os.MkdirAll(path.Dir(destPath), 0755); err != nil { 712 return err 713 } 714 if err := chrootarchive.CopyWithTar(origPath, destPath); err != nil { 715 return err 716 } 717 718 resPath := destPath 719 if destExists && destStat.IsDir() { 720 resPath = path.Join(destPath, path.Base(origPath)) 721 } 722 723 return fixPermissions(origPath, resPath, 0, 0, destExists) 724 } 725 726 func copyAsDirectory(source, destination string, destExisted bool) error { 727 if err := chrootarchive.CopyWithTar(source, destination); err != nil { 728 return err 729 } 730 return fixPermissions(source, destination, 0, 0, destExisted) 731 } 732 733 func fixPermissions(source, destination string, uid, gid int, destExisted bool) error { 734 // If the destination didn't already exist, or the destination isn't a 735 // directory, then we should Lchown the destination. Otherwise, we shouldn't 736 // Lchown the destination. 737 destStat, err := os.Stat(destination) 738 if err != nil { 739 // This should *never* be reached, because the destination must've already 740 // been created while untar-ing the context. 741 return err 742 } 743 doChownDestination := !destExisted || !destStat.IsDir() 744 745 // We Walk on the source rather than on the destination because we don't 746 // want to change permissions on things we haven't created or modified. 747 return filepath.Walk(source, func(fullpath string, info os.FileInfo, err error) error { 748 // Do not alter the walk root iff. it existed before, as it doesn't fall under 749 // the domain of "things we should chown". 750 if !doChownDestination && (source == fullpath) { 751 return nil 752 } 753 754 // Path is prefixed by source: substitute with destination instead. 755 cleaned, err := filepath.Rel(source, fullpath) 756 if err != nil { 757 return err 758 } 759 760 fullpath = path.Join(destination, cleaned) 761 return os.Lchown(fullpath, uid, gid) 762 }) 763 } 764 765 func (b *Builder) clearTmp() { 766 for c := range b.TmpContainers { 767 rmConfig := &daemon.ContainerRmConfig{ 768 ForceRemove: true, 769 RemoveVolume: true, 770 } 771 if err := b.Daemon.ContainerRm(c, rmConfig); err != nil { 772 fmt.Fprintf(b.OutStream, "Error removing intermediate container %s: %v\n", stringid.TruncateID(c), err) 773 return 774 } 775 delete(b.TmpContainers, c) 776 fmt.Fprintf(b.OutStream, "Removing intermediate container %s\n", stringid.TruncateID(c)) 777 } 778 }