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