github.com/joselitofilho/goreleaser@v0.155.1-0.20210123221854-e4891856c593/internal/pipe/snapcraft/snapcraft.go (about)

     1  // Package snapcraft implements the Pipe interface providing Snapcraft bindings.
     2  package snapcraft
     3  
     4  import (
     5  	"errors"
     6  	"fmt"
     7  	"io/ioutil"
     8  	"os"
     9  	"os/exec"
    10  	"path/filepath"
    11  	"strings"
    12  
    13  	"github.com/apex/log"
    14  	"gopkg.in/yaml.v2"
    15  
    16  	"github.com/goreleaser/goreleaser/internal/artifact"
    17  	"github.com/goreleaser/goreleaser/internal/ids"
    18  	"github.com/goreleaser/goreleaser/internal/linux"
    19  	"github.com/goreleaser/goreleaser/internal/pipe"
    20  	"github.com/goreleaser/goreleaser/internal/semerrgroup"
    21  	"github.com/goreleaser/goreleaser/internal/tmpl"
    22  	"github.com/goreleaser/goreleaser/pkg/config"
    23  	"github.com/goreleaser/goreleaser/pkg/context"
    24  )
    25  
    26  // ErrNoSnapcraft is shown when snapcraft cannot be found in $PATH.
    27  var ErrNoSnapcraft = errors.New("snapcraft not present in $PATH")
    28  
    29  // ErrNoDescription is shown when no description provided.
    30  var ErrNoDescription = errors.New("no description provided for snapcraft")
    31  
    32  // ErrNoSummary is shown when no summary provided.
    33  var ErrNoSummary = errors.New("no summary provided for snapcraft")
    34  
    35  // Metadata to generate the snap package.
    36  type Metadata struct {
    37  	Name          string
    38  	Version       string
    39  	Summary       string
    40  	Description   string
    41  	Base          string `yaml:",omitempty"`
    42  	License       string `yaml:",omitempty"`
    43  	Grade         string `yaml:",omitempty"`
    44  	Confinement   string `yaml:",omitempty"`
    45  	Architectures []string
    46  	Apps          map[string]AppMetadata
    47  	Plugs         map[string]interface{} `yaml:",omitempty"`
    48  }
    49  
    50  // AppMetadata for the binaries that will be in the snap package.
    51  type AppMetadata struct {
    52  	Command          string
    53  	Plugs            []string `yaml:",omitempty"`
    54  	Daemon           string   `yaml:",omitempty"`
    55  	Completer        string   `yaml:",omitempty"`
    56  	RestartCondition string   `yaml:"restart-condition,omitempty"`
    57  }
    58  
    59  const defaultNameTemplate = "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}"
    60  
    61  // Pipe for snapcraft packaging.
    62  type Pipe struct{}
    63  
    64  func (Pipe) String() string {
    65  	return "snapcraft packages"
    66  }
    67  
    68  // Default sets the pipe defaults.
    69  func (Pipe) Default(ctx *context.Context) error {
    70  	var ids = ids.New("snapcrafts")
    71  	for i := range ctx.Config.Snapcrafts {
    72  		var snap = &ctx.Config.Snapcrafts[i]
    73  		if snap.NameTemplate == "" {
    74  			snap.NameTemplate = defaultNameTemplate
    75  		}
    76  		if len(snap.Builds) == 0 {
    77  			for _, b := range ctx.Config.Builds {
    78  				snap.Builds = append(snap.Builds, b.ID)
    79  			}
    80  		}
    81  		ids.Inc(snap.ID)
    82  	}
    83  	return ids.Validate()
    84  }
    85  
    86  // Run the pipe.
    87  func (Pipe) Run(ctx *context.Context) error {
    88  	for _, snap := range ctx.Config.Snapcrafts {
    89  		// TODO: deal with pipe.skip?
    90  		if err := doRun(ctx, snap); err != nil {
    91  			return err
    92  		}
    93  	}
    94  	return nil
    95  }
    96  
    97  func doRun(ctx *context.Context, snap config.Snapcraft) error {
    98  	if snap.Summary == "" && snap.Description == "" {
    99  		return pipe.Skip("no summary nor description were provided")
   100  	}
   101  	if snap.Summary == "" {
   102  		return ErrNoSummary
   103  	}
   104  	if snap.Description == "" {
   105  		return ErrNoDescription
   106  	}
   107  	_, err := exec.LookPath("snapcraft")
   108  	if err != nil {
   109  		return ErrNoSnapcraft
   110  	}
   111  
   112  	var g = semerrgroup.New(ctx.Parallelism)
   113  	for platform, binaries := range ctx.Artifacts.Filter(
   114  		artifact.And(
   115  			artifact.ByGoos("linux"),
   116  			artifact.ByType(artifact.Binary),
   117  			artifact.ByIDs(snap.Builds...),
   118  		),
   119  	).GroupByPlatform() {
   120  		arch := linux.Arch(platform)
   121  		if !isValidArch(arch) {
   122  			log.WithField("arch", arch).Warn("ignored unsupported arch")
   123  			continue
   124  		}
   125  		binaries := binaries
   126  		g.Go(func() error {
   127  			return create(ctx, snap, arch, binaries)
   128  		})
   129  	}
   130  	return g.Wait()
   131  }
   132  
   133  func isValidArch(arch string) bool {
   134  	// https://snapcraft.io/docs/architectures
   135  	for _, a := range []string{"s390x", "ppc64el", "arm64", "armhf", "amd64", "i386"} {
   136  		if arch == a {
   137  			return true
   138  		}
   139  	}
   140  	return false
   141  }
   142  
   143  // Publish packages.
   144  func (Pipe) Publish(ctx *context.Context) error {
   145  	if ctx.SkipPublish {
   146  		return pipe.ErrSkipPublishEnabled
   147  	}
   148  	snaps := ctx.Artifacts.Filter(artifact.ByType(artifact.PublishableSnapcraft)).List()
   149  	var g = semerrgroup.New(ctx.Parallelism)
   150  	for _, snap := range snaps {
   151  		snap := snap
   152  		g.Go(func() error {
   153  			return push(ctx, snap)
   154  		})
   155  	}
   156  	return g.Wait()
   157  }
   158  
   159  func create(ctx *context.Context, snap config.Snapcraft, arch string, binaries []*artifact.Artifact) error {
   160  	var log = log.WithField("arch", arch)
   161  	folder, err := tmpl.New(ctx).
   162  		WithArtifact(binaries[0], snap.Replacements).
   163  		Apply(snap.NameTemplate)
   164  	if err != nil {
   165  		return err
   166  	}
   167  
   168  	// prime is the directory that then will be compressed to make the .snap package.
   169  	var folderDir = filepath.Join(ctx.Config.Dist, folder)
   170  	var primeDir = filepath.Join(folderDir, "prime")
   171  	var metaDir = filepath.Join(primeDir, "meta")
   172  	// #nosec
   173  	if err = os.MkdirAll(metaDir, 0755); err != nil {
   174  		return err
   175  	}
   176  
   177  	for _, file := range snap.Files {
   178  		if file.Destination == "" {
   179  			file.Destination = file.Source
   180  		}
   181  		if file.Mode == 0 {
   182  			file.Mode = 0644
   183  		}
   184  		if err := os.MkdirAll(filepath.Join(primeDir, filepath.Dir(file.Destination)), 0755); err != nil {
   185  			return fmt.Errorf("failed to link extra file '%s': %w", file.Source, err)
   186  		}
   187  		if err := link(file.Source, filepath.Join(primeDir, file.Destination), os.FileMode(file.Mode)); err != nil {
   188  			return fmt.Errorf("failed to link extra file '%s': %w", file.Source, err)
   189  		}
   190  	}
   191  
   192  	var file = filepath.Join(primeDir, "meta", "snap.yaml")
   193  	log.WithField("file", file).Debug("creating snap metadata")
   194  
   195  	var metadata = &Metadata{
   196  		Version:       ctx.Version,
   197  		Summary:       snap.Summary,
   198  		Description:   snap.Description,
   199  		Grade:         snap.Grade,
   200  		Confinement:   snap.Confinement,
   201  		Architectures: []string{arch},
   202  		Apps:          map[string]AppMetadata{},
   203  	}
   204  
   205  	if snap.Base != "" {
   206  		metadata.Base = snap.Base
   207  	}
   208  
   209  	if snap.License != "" {
   210  		metadata.License = snap.License
   211  	}
   212  
   213  	metadata.Name = ctx.Config.ProjectName
   214  	if snap.Name != "" {
   215  		metadata.Name = snap.Name
   216  	}
   217  
   218  	// if the user didn't specify any apps then
   219  	// default to the main binary being the command:
   220  	if len(snap.Apps) == 0 {
   221  		var name = snap.Name
   222  		if name == "" {
   223  			name = binaries[0].Name
   224  		}
   225  		metadata.Apps[name] = AppMetadata{
   226  			Command: filepath.Base(binaries[0].Name),
   227  		}
   228  	}
   229  
   230  	for _, binary := range binaries {
   231  		// build the binaries and link resources
   232  		destBinaryPath := filepath.Join(primeDir, filepath.Base(binary.Path))
   233  		log.WithField("src", binary.Path).
   234  			WithField("dst", destBinaryPath).
   235  			Debug("linking")
   236  
   237  		if err = os.Link(binary.Path, destBinaryPath); err != nil {
   238  			return fmt.Errorf("failed to link binary: %w", err)
   239  		}
   240  		if err := os.Chmod(destBinaryPath, 0555); err != nil {
   241  			return fmt.Errorf("failed to change binary permissions: %w", err)
   242  		}
   243  	}
   244  
   245  	// setup the apps: directive for each binary
   246  	for name, config := range snap.Apps {
   247  		command := name
   248  		if config.Command != "" {
   249  			command = config.Command
   250  		}
   251  
   252  		// TODO: test that the correct binary is used in Command
   253  		// See https://github.com/goreleaser/goreleaser/pull/1449
   254  		appMetadata := AppMetadata{
   255  			Command: strings.TrimSpace(strings.Join([]string{
   256  				command,
   257  				config.Args,
   258  			}, " ")),
   259  			Plugs:            config.Plugs,
   260  			Daemon:           config.Daemon,
   261  			RestartCondition: config.RestartCondition,
   262  		}
   263  
   264  		if config.Completer != "" {
   265  			destCompleterPath := filepath.Join(primeDir, config.Completer)
   266  			if err := os.MkdirAll(filepath.Dir(destCompleterPath), 0755); err != nil {
   267  				return fmt.Errorf("failed to create folder: %w", err)
   268  			}
   269  			log.WithField("src", config.Completer).
   270  				WithField("dst", destCompleterPath).
   271  				Debug("linking")
   272  			if err := os.Link(config.Completer, destCompleterPath); err != nil {
   273  				return fmt.Errorf("failed to link completer: %w", err)
   274  			}
   275  			if err := os.Chmod(destCompleterPath, 0644); err != nil {
   276  				return fmt.Errorf("failed to change completer permissions: %w", err)
   277  			}
   278  			appMetadata.Completer = config.Completer
   279  		}
   280  
   281  		metadata.Apps[name] = appMetadata
   282  		metadata.Plugs = snap.Plugs
   283  	}
   284  
   285  	out, err := yaml.Marshal(metadata)
   286  	if err != nil {
   287  		return err
   288  	}
   289  
   290  	log.WithField("file", file).Debugf("writing metadata file")
   291  	if err = ioutil.WriteFile(file, out, 0644); err != nil { //nolint: gosec
   292  		return err
   293  	}
   294  
   295  	var snapFile = filepath.Join(ctx.Config.Dist, folder+".snap")
   296  	log.WithField("snap", snapFile).Info("creating")
   297  	/* #nosec */
   298  	var cmd = exec.CommandContext(ctx, "snapcraft", "pack", primeDir, "--output", snapFile)
   299  	if out, err = cmd.CombinedOutput(); err != nil {
   300  		return fmt.Errorf("failed to generate snap package: %s", string(out))
   301  	}
   302  	if !snap.Publish {
   303  		return nil
   304  	}
   305  	ctx.Artifacts.Add(&artifact.Artifact{
   306  		Type:   artifact.PublishableSnapcraft,
   307  		Name:   folder + ".snap",
   308  		Path:   snapFile,
   309  		Goos:   binaries[0].Goos,
   310  		Goarch: binaries[0].Goarch,
   311  		Goarm:  binaries[0].Goarm,
   312  	})
   313  	return nil
   314  }
   315  
   316  const reviewWaitMsg = `Waiting for previous upload(s) to complete their review process.`
   317  
   318  func push(ctx *context.Context, snap *artifact.Artifact) error {
   319  	var log = log.WithField("snap", snap.Name)
   320  	log.Info("pushing snap")
   321  	// TODO: customize --release based on snap.Grade?
   322  	/* #nosec */
   323  	var cmd = exec.CommandContext(ctx, "snapcraft", "push", "--release=stable", snap.Path)
   324  	if out, err := cmd.CombinedOutput(); err != nil {
   325  		if strings.Contains(string(out), reviewWaitMsg) {
   326  			log.Warn(reviewWaitMsg)
   327  		} else {
   328  			return fmt.Errorf("failed to push %s package: %s", snap.Path, string(out))
   329  		}
   330  	}
   331  	snap.Type = artifact.Snapcraft
   332  	ctx.Artifacts.Add(snap)
   333  	return nil
   334  }
   335  
   336  // walks the src, recreating dirs and hard-linking files.
   337  func link(src, dest string, mode os.FileMode) error {
   338  	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
   339  		if err != nil {
   340  			return err
   341  		}
   342  		// We have the following:
   343  		// - src = "a/b"
   344  		// - dest = "dist/linuxamd64/b"
   345  		// - path = "a/b/c.txt"
   346  		// So we join "a/b" with "c.txt" and use it as the destination.
   347  		var dst = filepath.Join(dest, strings.Replace(path, src, "", 1))
   348  		log.WithFields(log.Fields{
   349  			"src": path,
   350  			"dst": dst,
   351  		}).Debug("extra file")
   352  		if info.IsDir() {
   353  			return os.MkdirAll(dst, info.Mode())
   354  		}
   355  		if err := os.Link(path, dst); err != nil {
   356  			return err
   357  		}
   358  		return os.Chmod(dst, mode)
   359  	})
   360  }