github.com/windmeup/goreleaser@v1.21.95/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/windmeup/goreleaser/internal/artifact"
    15  	"github.com/windmeup/goreleaser/internal/gio"
    16  	"github.com/windmeup/goreleaser/internal/ids"
    17  	"github.com/windmeup/goreleaser/internal/pipe"
    18  	"github.com/windmeup/goreleaser/internal/semerrgroup"
    19  	"github.com/windmeup/goreleaser/internal/skips"
    20  	"github.com/windmeup/goreleaser/internal/tmpl"
    21  	"github.com/windmeup/goreleaser/pkg/config"
    22  	"github.com/windmeup/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  			log.WithField("artifacts", artifacts.Paths()).Debug("found artifacts")
   146  			return process(ctx, docker, artifacts.List())
   147  		})
   148  	}
   149  	if err := g.Wait(); err != nil {
   150  		if pipe.IsSkip(err) {
   151  			return err
   152  		}
   153  		return fmt.Errorf("docker build failed: %w\nLearn more at https://goreleaser.com/errors/docker-build\n", err) // nolint:revive
   154  	}
   155  	return nil
   156  }
   157  
   158  func process(ctx *context.Context, docker config.Docker, artifacts []*artifact.Artifact) error {
   159  	if len(artifacts) == 0 {
   160  		log.Warn("no binaries or packages found for the given platform - COPY/ADD may not work")
   161  	}
   162  	tmp, err := os.MkdirTemp("", "goreleaserdocker")
   163  	if err != nil {
   164  		return fmt.Errorf("failed to create temporary dir: %w", err)
   165  	}
   166  	defer os.RemoveAll(tmp)
   167  
   168  	images, err := processImageTemplates(ctx, docker)
   169  	if err != nil {
   170  		return err
   171  	}
   172  
   173  	if len(images) == 0 {
   174  		return pipe.Skip("no image templates found")
   175  	}
   176  
   177  	log := log.WithField("image", images[0])
   178  	log.Debug("tempdir: " + tmp)
   179  
   180  	if err := tmpl.New(ctx).ApplyAll(
   181  		&docker.Dockerfile,
   182  	); err != nil {
   183  		return err
   184  	}
   185  	if err := gio.Copy(
   186  		docker.Dockerfile,
   187  		filepath.Join(tmp, "Dockerfile"),
   188  	); err != nil {
   189  		return fmt.Errorf("failed to copy dockerfile: %w", err)
   190  	}
   191  
   192  	for _, file := range docker.Files {
   193  		if err := os.MkdirAll(filepath.Join(tmp, filepath.Dir(file)), 0o755); err != nil {
   194  			return fmt.Errorf("failed to copy extra file '%s': %w", file, err)
   195  		}
   196  		if err := gio.Copy(file, filepath.Join(tmp, file)); err != nil {
   197  			return fmt.Errorf("failed to copy extra file '%s': %w", file, err)
   198  		}
   199  	}
   200  	for _, art := range artifacts {
   201  		target := filepath.Join(tmp, art.Name)
   202  		if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
   203  			return fmt.Errorf("failed to make dir for artifact: %w", err)
   204  		}
   205  
   206  		if err := gio.Copy(art.Path, target); err != nil {
   207  			return fmt.Errorf("failed to copy artifact: %w", err)
   208  		}
   209  	}
   210  
   211  	buildFlags, err := processBuildFlagTemplates(ctx, docker)
   212  	if err != nil {
   213  		return err
   214  	}
   215  
   216  	log.Info("building docker image")
   217  	if err := imagers[docker.Use].Build(ctx, tmp, images, buildFlags); err != nil {
   218  		if isFileNotFoundError(err.Error()) {
   219  			var files []string
   220  			_ = filepath.Walk(tmp, func(_ string, info fs.FileInfo, _ error) error {
   221  				if info.IsDir() {
   222  					return nil
   223  				}
   224  				files = append(files, info.Name())
   225  				return nil
   226  			})
   227  			return fmt.Errorf(`seems like you tried to copy a file that is not available in the build context.
   228  
   229  Here's more information about the build context:
   230  
   231  dir: %q
   232  files in that dir:
   233   %s
   234  
   235  Previous error:
   236  %w`, tmp, strings.Join(files, "\n "), err)
   237  		}
   238  		if isBuildxContextError(err.Error()) {
   239  			return fmt.Errorf("docker buildx is not set to default context - please switch with 'docker context use default'")
   240  		}
   241  		return err
   242  	}
   243  
   244  	for _, img := range images {
   245  		ctx.Artifacts.Add(&artifact.Artifact{
   246  			Type:   artifact.PublishableDockerImage,
   247  			Name:   img,
   248  			Path:   img,
   249  			Goarch: docker.Goarch,
   250  			Goos:   docker.Goos,
   251  			Goarm:  docker.Goarm,
   252  			Extra: map[string]interface{}{
   253  				dockerConfigExtra: docker,
   254  			},
   255  		})
   256  	}
   257  	return nil
   258  }
   259  
   260  func isFileNotFoundError(out string) bool {
   261  	if strings.Contains(out, `executable file not found in $PATH`) {
   262  		return false
   263  	}
   264  	return strings.Contains(out, "file not found") ||
   265  		strings.Contains(out, ": not found")
   266  }
   267  
   268  func isBuildxContextError(out string) bool {
   269  	return strings.Contains(out, "to switch to context")
   270  }
   271  
   272  func processImageTemplates(ctx *context.Context, docker config.Docker) ([]string, error) {
   273  	// nolint:prealloc
   274  	var images []string
   275  	for _, imageTemplate := range docker.ImageTemplates {
   276  		image, err := tmpl.New(ctx).Apply(imageTemplate)
   277  		if err != nil {
   278  			return nil, fmt.Errorf("failed to execute image template '%s': %w", imageTemplate, err)
   279  		}
   280  		if image == "" {
   281  			continue
   282  		}
   283  
   284  		images = append(images, image)
   285  	}
   286  
   287  	return images, nil
   288  }
   289  
   290  func processBuildFlagTemplates(ctx *context.Context, docker config.Docker) ([]string, error) {
   291  	// nolint:prealloc
   292  	var buildFlags []string
   293  	for _, buildFlagTemplate := range docker.BuildFlagTemplates {
   294  		buildFlag, err := tmpl.New(ctx).Apply(buildFlagTemplate)
   295  		if err != nil {
   296  			return nil, fmt.Errorf("failed to process build flag template '%s': %w", buildFlagTemplate, err)
   297  		}
   298  		buildFlags = append(buildFlags, buildFlag)
   299  	}
   300  	return buildFlags, nil
   301  }
   302  
   303  func dockerPush(ctx *context.Context, image *artifact.Artifact) error {
   304  	log.WithField("image", image.Name).Info("pushing")
   305  
   306  	docker, err := artifact.Extra[config.Docker](*image, dockerConfigExtra)
   307  	if err != nil {
   308  		return err
   309  	}
   310  
   311  	skip, err := tmpl.New(ctx).Apply(docker.SkipPush)
   312  	if err != nil {
   313  		return err
   314  	}
   315  	if strings.TrimSpace(skip) == "true" {
   316  		return pipe.Skip("docker.skip_push is set: " + image.Name)
   317  	}
   318  	if strings.TrimSpace(skip) == "auto" && ctx.Semver.Prerelease != "" {
   319  		return pipe.Skip("prerelease detected with 'auto' push, skipping docker publish: " + image.Name)
   320  	}
   321  
   322  	digest, err := doPush(ctx, imagers[docker.Use], image.Name, docker.PushFlags)
   323  	if err != nil {
   324  		return err
   325  	}
   326  
   327  	art := &artifact.Artifact{
   328  		Type:   artifact.DockerImage,
   329  		Name:   image.Name,
   330  		Path:   image.Path,
   331  		Goarch: image.Goarch,
   332  		Goos:   image.Goos,
   333  		Goarm:  image.Goarm,
   334  		Extra:  map[string]interface{}{},
   335  	}
   336  	if docker.ID != "" {
   337  		art.Extra[artifact.ExtraID] = docker.ID
   338  	}
   339  	art.Extra[artifact.ExtraDigest] = digest
   340  
   341  	ctx.Artifacts.Add(art)
   342  	return nil
   343  }
   344  
   345  func doPush(ctx *context.Context, img imager, name string, flags []string) (string, error) {
   346  	var try int
   347  	for try < 10 {
   348  		digest, err := img.Push(ctx, name, flags)
   349  		if err == nil {
   350  			return digest, nil
   351  		}
   352  		if isRetryable(err) {
   353  			log.WithField("try", try).
   354  				WithField("image", name).
   355  				WithError(err).
   356  				Warnf("failed to push image, will retry")
   357  			time.Sleep(time.Duration(try*10) * time.Second)
   358  			try++
   359  			continue
   360  		}
   361  		return "", fmt.Errorf("failed to push %s after %d tries: %w", name, try, err)
   362  	}
   363  	return "", nil // will never happen
   364  }
   365  
   366  func isRetryable(err error) bool {
   367  	for _, code := range []int{
   368  		http.StatusInternalServerError,
   369  		// http.StatusNotImplemented,
   370  		http.StatusBadGateway,
   371  		http.StatusServiceUnavailable,
   372  		http.StatusGatewayTimeout,
   373  		// http.StatusHTTPVersionNotSupported,
   374  		http.StatusVariantAlsoNegotiates,
   375  		// http.StatusInsufficientStorage,
   376  		// http.StatusLoopDetected,
   377  		http.StatusNotExtended,
   378  		// http.StatusNetworkAuthenticationRequired,
   379  	} {
   380  		if strings.Contains(
   381  			err.Error(),
   382  			fmt.Sprintf(
   383  				"received unexpected HTTP status: %d %s",
   384  				code,
   385  				http.StatusText(code),
   386  			),
   387  		) {
   388  			return true
   389  		}
   390  	}
   391  	return false
   392  }