github.com/goreleaser/goreleaser@v1.25.1/internal/pipe/docker/docker.go (about)

     1  package docker
     2  
     3  import (
     4  	"fmt"
     5  	"io/fs"
     6  	"net/http"
     7  	"os"
     8  	"path/filepath"
     9  	"sort"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/caarlos0/log"
    14  	"github.com/goreleaser/goreleaser/internal/artifact"
    15  	"github.com/goreleaser/goreleaser/internal/gio"
    16  	"github.com/goreleaser/goreleaser/internal/ids"
    17  	"github.com/goreleaser/goreleaser/internal/pipe"
    18  	"github.com/goreleaser/goreleaser/internal/semerrgroup"
    19  	"github.com/goreleaser/goreleaser/internal/skips"
    20  	"github.com/goreleaser/goreleaser/internal/tmpl"
    21  	"github.com/goreleaser/goreleaser/pkg/config"
    22  	"github.com/goreleaser/goreleaser/pkg/context"
    23  )
    24  
    25  const (
    26  	dockerConfigExtra = "DockerConfig"
    27  
    28  	useBuildx = "buildx"
    29  	useDocker = "docker"
    30  )
    31  
    32  // Pipe for docker.
    33  type Pipe struct{}
    34  
    35  func (Pipe) String() string { return "docker images" }
    36  
    37  func (Pipe) Skip(ctx *context.Context) bool {
    38  	return len(ctx.Config.Dockers) == 0 || skips.Any(ctx, skips.Docker)
    39  }
    40  
    41  func (Pipe) Dependencies(ctx *context.Context) []string {
    42  	var cmds []string
    43  	for _, s := range ctx.Config.Dockers {
    44  		switch s.Use {
    45  		case useDocker, useBuildx:
    46  			cmds = append(cmds, "docker")
    47  			// TODO: how to check if buildx is installed
    48  		}
    49  	}
    50  	return cmds
    51  }
    52  
    53  // Default sets the pipe defaults.
    54  func (Pipe) Default(ctx *context.Context) error {
    55  	ids := ids.New("dockers")
    56  	for i := range ctx.Config.Dockers {
    57  		docker := &ctx.Config.Dockers[i]
    58  
    59  		if docker.ID != "" {
    60  			ids.Inc(docker.ID)
    61  		}
    62  		if docker.Goos == "" {
    63  			docker.Goos = "linux"
    64  		}
    65  		if docker.Goarch == "" {
    66  			docker.Goarch = "amd64"
    67  		}
    68  		if docker.Goarm == "" {
    69  			docker.Goarm = "6"
    70  		}
    71  		if docker.Goamd64 == "" {
    72  			docker.Goamd64 = "v1"
    73  		}
    74  		if docker.Dockerfile == "" {
    75  			docker.Dockerfile = "Dockerfile"
    76  		}
    77  		if docker.Use == "" {
    78  			docker.Use = useDocker
    79  		}
    80  		if err := validateImager(docker.Use); err != nil {
    81  			return err
    82  		}
    83  	}
    84  	return ids.Validate()
    85  }
    86  
    87  func validateImager(use string) error {
    88  	valid := make([]string, 0, len(imagers))
    89  	for k := range imagers {
    90  		valid = append(valid, k)
    91  	}
    92  	for _, s := range valid {
    93  		if s == use {
    94  			return nil
    95  		}
    96  	}
    97  	sort.Strings(valid)
    98  	return fmt.Errorf("docker: invalid use: %s, valid options are %v", use, valid)
    99  }
   100  
   101  // Publish the docker images.
   102  func (Pipe) Publish(ctx *context.Context) error {
   103  	skips := pipe.SkipMemento{}
   104  	images := ctx.Artifacts.Filter(artifact.ByType(artifact.PublishableDockerImage)).List()
   105  	for _, image := range images {
   106  		if err := dockerPush(ctx, image); err != nil {
   107  			if pipe.IsSkip(err) {
   108  				skips.Remember(err)
   109  				continue
   110  			}
   111  			return err
   112  		}
   113  	}
   114  	return skips.Evaluate()
   115  }
   116  
   117  // Run the pipe.
   118  func (Pipe) Run(ctx *context.Context) error {
   119  	g := semerrgroup.NewSkipAware(semerrgroup.New(ctx.Parallelism))
   120  	for i, docker := range ctx.Config.Dockers {
   121  		i := i
   122  		docker := docker
   123  		g.Go(func() error {
   124  			log := log.WithField("index", i)
   125  			log.Debug("looking for artifacts matching")
   126  			filters := []artifact.Filter{
   127  				artifact.ByGoos(docker.Goos),
   128  				artifact.ByGoarch(docker.Goarch),
   129  				artifact.Or(
   130  					artifact.ByType(artifact.Binary),
   131  					artifact.ByType(artifact.LinuxPackage),
   132  				),
   133  			}
   134  			// TODO: properly test this
   135  			switch docker.Goarch {
   136  			case "amd64":
   137  				filters = append(filters, artifact.ByGoamd64(docker.Goamd64))
   138  			case "arm":
   139  				filters = append(filters, artifact.ByGoarm(docker.Goarm))
   140  			}
   141  			if len(docker.IDs) > 0 {
   142  				filters = append(filters, artifact.ByIDs(docker.IDs...))
   143  			}
   144  			artifacts := ctx.Artifacts.Filter(artifact.And(filters...))
   145  			if d := len(docker.IDs); d > 0 && len(artifacts.GroupByID()) != d {
   146  				return pipe.Skipf("expected to find %d artifacts for ids %v, found %d\nLearn more at https://goreleaser.com/errors/docker-build\n", d, docker.IDs, len(artifacts.List()))
   147  			}
   148  			log.WithField("artifacts", artifacts.Paths()).Debug("found artifacts")
   149  			return process(ctx, docker, artifacts.List())
   150  		})
   151  	}
   152  	if err := g.Wait(); err != nil {
   153  		if pipe.IsSkip(err) {
   154  			return err
   155  		}
   156  		return fmt.Errorf("docker build failed: %w\nLearn more at https://goreleaser.com/errors/docker-build\n", err) // nolint:revive
   157  	}
   158  	return nil
   159  }
   160  
   161  func process(ctx *context.Context, docker config.Docker, artifacts []*artifact.Artifact) error {
   162  	if len(artifacts) == 0 {
   163  		log.Warn("no binaries or packages found for the given platform - COPY/ADD may not work")
   164  	}
   165  	tmp, err := os.MkdirTemp("", "goreleaserdocker")
   166  	if err != nil {
   167  		return fmt.Errorf("failed to create temporary dir: %w", err)
   168  	}
   169  	defer os.RemoveAll(tmp)
   170  
   171  	images, err := processImageTemplates(ctx, docker)
   172  	if err != nil {
   173  		return err
   174  	}
   175  
   176  	if len(images) == 0 {
   177  		return pipe.Skip("no image templates found")
   178  	}
   179  
   180  	log := log.WithField("image", images[0])
   181  	log.Debug("tempdir: " + tmp)
   182  
   183  	if err := tmpl.New(ctx).ApplyAll(
   184  		&docker.Dockerfile,
   185  	); err != nil {
   186  		return err
   187  	}
   188  	if err := gio.Copy(
   189  		docker.Dockerfile,
   190  		filepath.Join(tmp, "Dockerfile"),
   191  	); err != nil {
   192  		return fmt.Errorf("failed to copy dockerfile: %w", err)
   193  	}
   194  
   195  	for _, file := range docker.Files {
   196  		if err := os.MkdirAll(filepath.Join(tmp, filepath.Dir(file)), 0o755); err != nil {
   197  			return fmt.Errorf("failed to copy extra file '%s': %w", file, err)
   198  		}
   199  		if err := gio.Copy(file, filepath.Join(tmp, file)); err != nil {
   200  			return fmt.Errorf("failed to copy extra file '%s': %w", file, err)
   201  		}
   202  	}
   203  	for _, art := range artifacts {
   204  		target := filepath.Join(tmp, art.Name)
   205  		if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
   206  			return fmt.Errorf("failed to make dir for artifact: %w", err)
   207  		}
   208  
   209  		if err := gio.Copy(art.Path, target); err != nil {
   210  			return fmt.Errorf("failed to copy artifact: %w", err)
   211  		}
   212  	}
   213  
   214  	buildFlags, err := processBuildFlagTemplates(ctx, docker)
   215  	if err != nil {
   216  		return err
   217  	}
   218  
   219  	log.Info("building docker image")
   220  	if err := imagers[docker.Use].Build(ctx, tmp, images, buildFlags); err != nil {
   221  		if isFileNotFoundError(err.Error()) {
   222  			var files []string
   223  			_ = filepath.Walk(tmp, func(_ string, info fs.FileInfo, _ error) error {
   224  				if info.IsDir() {
   225  					return nil
   226  				}
   227  				files = append(files, info.Name())
   228  				return nil
   229  			})
   230  			return fmt.Errorf(`seems like you tried to copy a file that is not available in the build context.
   231  
   232  Here's more information about the build context:
   233  
   234  dir: %q
   235  files in that dir:
   236   %s
   237  
   238  Previous error:
   239  %w`, tmp, strings.Join(files, "\n "), err)
   240  		}
   241  		if isBuildxContextError(err.Error()) {
   242  			return fmt.Errorf("docker buildx is not set to default context - please switch with 'docker context use default'")
   243  		}
   244  		return err
   245  	}
   246  
   247  	for _, img := range images {
   248  		ctx.Artifacts.Add(&artifact.Artifact{
   249  			Type:   artifact.PublishableDockerImage,
   250  			Name:   img,
   251  			Path:   img,
   252  			Goarch: docker.Goarch,
   253  			Goos:   docker.Goos,
   254  			Goarm:  docker.Goarm,
   255  			Extra: map[string]interface{}{
   256  				dockerConfigExtra: docker,
   257  			},
   258  		})
   259  	}
   260  	return nil
   261  }
   262  
   263  func isFileNotFoundError(out string) bool {
   264  	if strings.Contains(out, `executable file not found in $PATH`) {
   265  		return false
   266  	}
   267  	return strings.Contains(out, "file not found") ||
   268  		strings.Contains(out, ": not found")
   269  }
   270  
   271  func isBuildxContextError(out string) bool {
   272  	return strings.Contains(out, "to switch to context")
   273  }
   274  
   275  func processImageTemplates(ctx *context.Context, docker config.Docker) ([]string, error) {
   276  	// nolint:prealloc
   277  	var images []string
   278  	for _, imageTemplate := range docker.ImageTemplates {
   279  		image, err := tmpl.New(ctx).Apply(imageTemplate)
   280  		if err != nil {
   281  			return nil, fmt.Errorf("failed to execute image template '%s': %w", imageTemplate, err)
   282  		}
   283  		if image == "" {
   284  			continue
   285  		}
   286  
   287  		images = append(images, image)
   288  	}
   289  
   290  	return images, nil
   291  }
   292  
   293  func processBuildFlagTemplates(ctx *context.Context, docker config.Docker) ([]string, error) {
   294  	// nolint:prealloc
   295  	var buildFlags []string
   296  	for _, buildFlagTemplate := range docker.BuildFlagTemplates {
   297  		buildFlag, err := tmpl.New(ctx).Apply(buildFlagTemplate)
   298  		if err != nil {
   299  			return nil, fmt.Errorf("failed to process build flag template '%s': %w", buildFlagTemplate, err)
   300  		}
   301  		buildFlags = append(buildFlags, buildFlag)
   302  	}
   303  	return buildFlags, nil
   304  }
   305  
   306  func dockerPush(ctx *context.Context, image *artifact.Artifact) error {
   307  	log.WithField("image", image.Name).Info("pushing")
   308  
   309  	docker, err := artifact.Extra[config.Docker](*image, dockerConfigExtra)
   310  	if err != nil {
   311  		return err
   312  	}
   313  
   314  	skip, err := tmpl.New(ctx).Apply(docker.SkipPush)
   315  	if err != nil {
   316  		return err
   317  	}
   318  	if strings.TrimSpace(skip) == "true" {
   319  		return pipe.Skip("docker.skip_push is set: " + image.Name)
   320  	}
   321  	if strings.TrimSpace(skip) == "auto" && ctx.Semver.Prerelease != "" {
   322  		return pipe.Skip("prerelease detected with 'auto' push, skipping docker publish: " + image.Name)
   323  	}
   324  
   325  	digest, err := doPush(ctx, imagers[docker.Use], image.Name, docker.PushFlags)
   326  	if err != nil {
   327  		return err
   328  	}
   329  
   330  	art := &artifact.Artifact{
   331  		Type:   artifact.DockerImage,
   332  		Name:   image.Name,
   333  		Path:   image.Path,
   334  		Goarch: image.Goarch,
   335  		Goos:   image.Goos,
   336  		Goarm:  image.Goarm,
   337  		Extra:  map[string]interface{}{},
   338  	}
   339  	if docker.ID != "" {
   340  		art.Extra[artifact.ExtraID] = docker.ID
   341  	}
   342  	art.Extra[artifact.ExtraDigest] = digest
   343  
   344  	ctx.Artifacts.Add(art)
   345  	return nil
   346  }
   347  
   348  func doPush(ctx *context.Context, img imager, name string, flags []string) (string, error) {
   349  	var try int
   350  	for try < 10 {
   351  		digest, err := img.Push(ctx, name, flags)
   352  		if err == nil {
   353  			return digest, nil
   354  		}
   355  		if isRetryable(err) {
   356  			log.WithField("try", try).
   357  				WithField("image", name).
   358  				WithError(err).
   359  				Warnf("failed to push image, will retry")
   360  			time.Sleep(time.Duration(try*10) * time.Second)
   361  			try++
   362  			continue
   363  		}
   364  		return "", fmt.Errorf("failed to push %s after %d tries: %w", name, try, err)
   365  	}
   366  	return "", nil // will never happen
   367  }
   368  
   369  func isRetryable(err error) bool {
   370  	for _, code := range []int{
   371  		http.StatusInternalServerError,
   372  		// http.StatusNotImplemented,
   373  		http.StatusBadGateway,
   374  		http.StatusServiceUnavailable,
   375  		http.StatusGatewayTimeout,
   376  		// http.StatusHTTPVersionNotSupported,
   377  		http.StatusVariantAlsoNegotiates,
   378  		// http.StatusInsufficientStorage,
   379  		// http.StatusLoopDetected,
   380  		http.StatusNotExtended,
   381  		// http.StatusNetworkAuthenticationRequired,
   382  	} {
   383  		if strings.Contains(
   384  			err.Error(),
   385  			fmt.Sprintf(
   386  				"received unexpected HTTP status: %d %s",
   387  				code,
   388  				http.StatusText(code),
   389  			),
   390  		) {
   391  			return true
   392  		}
   393  	}
   394  	return false
   395  }