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

     1  // Package snapcraft implements the Pipe interface providing Snapcraft bindings.
     2  //
     3  // nolint:tagliatelle
     4  package snapcraft
     5  
     6  import (
     7  	"errors"
     8  	"fmt"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"strings"
    13  
    14  	"github.com/caarlos0/log"
    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/pipe"
    19  	"github.com/goreleaser/goreleaser/internal/semerrgroup"
    20  	"github.com/goreleaser/goreleaser/internal/skips"
    21  	"github.com/goreleaser/goreleaser/internal/tmpl"
    22  	"github.com/goreleaser/goreleaser/internal/yaml"
    23  	"github.com/goreleaser/goreleaser/pkg/config"
    24  	"github.com/goreleaser/goreleaser/pkg/context"
    25  )
    26  
    27  const releasesExtra = "releases"
    28  
    29  // ErrNoSnapcraft is shown when snapcraft cannot be found in $PATH.
    30  var ErrNoSnapcraft = errors.New("snapcraft not present in $PATH")
    31  
    32  // ErrNoDescription is shown when no description provided.
    33  var ErrNoDescription = errors.New("no description provided for snapcraft")
    34  
    35  // ErrNoSummary is shown when no summary provided.
    36  var ErrNoSummary = errors.New("no summary provided for snapcraft")
    37  
    38  // Metadata to generate the snap package.
    39  type Metadata struct {
    40  	Name          string
    41  	Title         string `yaml:",omitempty"`
    42  	Version       string
    43  	Summary       string
    44  	Description   string
    45  	Icon          string `yaml:",omitempty"`
    46  	Base          string `yaml:",omitempty"`
    47  	License       string `yaml:",omitempty"`
    48  	Grade         string `yaml:",omitempty"`
    49  	Confinement   string `yaml:",omitempty"`
    50  	Architectures []string
    51  	Assumes       []string                  `yaml:",omitempty"`
    52  	Layout        map[string]LayoutMetadata `yaml:",omitempty"`
    53  	Apps          map[string]AppMetadata
    54  	Hooks         map[string]interface{} `yaml:",omitempty"`
    55  	Plugs         map[string]interface{} `yaml:",omitempty"`
    56  }
    57  
    58  // AppMetadata for the binaries that will be in the snap package.
    59  // See: https://snapcraft.io/docs/snapcraft-app-and-service-metadata
    60  type AppMetadata struct {
    61  	Command string
    62  
    63  	Adapter          string                 `yaml:",omitempty"`
    64  	After            []string               `yaml:",omitempty"`
    65  	Aliases          []string               `yaml:",omitempty"`
    66  	Autostart        string                 `yaml:",omitempty"`
    67  	Before           []string               `yaml:",omitempty"`
    68  	BusName          string                 `yaml:"bus-name,omitempty"`
    69  	CommandChain     []string               `yaml:"command-chain,omitempty"`
    70  	CommonID         string                 `yaml:"common-id,omitempty"`
    71  	Completer        string                 `yaml:",omitempty"`
    72  	Daemon           string                 `yaml:",omitempty"`
    73  	Desktop          string                 `yaml:",omitempty"`
    74  	Environment      map[string]interface{} `yaml:",omitempty"`
    75  	Extensions       []string               `yaml:",omitempty"`
    76  	InstallMode      string                 `yaml:"install-mode,omitempty"`
    77  	Passthrough      map[string]interface{} `yaml:",omitempty"`
    78  	Plugs            []string               `yaml:",omitempty"`
    79  	PostStopCommand  string                 `yaml:"post-stop-command,omitempty"`
    80  	RefreshMode      string                 `yaml:"refresh-mode,omitempty"`
    81  	ReloadCommand    string                 `yaml:"reload-command,omitempty"`
    82  	RestartCondition string                 `yaml:"restart-condition,omitempty"`
    83  	RestartDelay     string                 `yaml:"restart-delay,omitempty"`
    84  	Slots            []string               `yaml:",omitempty"`
    85  	Sockets          map[string]interface{} `yaml:",omitempty"`
    86  	StartTimeout     string                 `yaml:"start-timeout,omitempty"`
    87  	StopCommand      string                 `yaml:"stop-command,omitempty"`
    88  	StopMode         string                 `yaml:"stop-mode,omitempty"`
    89  	StopTimeout      string                 `yaml:"stop-timeout,omitempty"`
    90  	Timer            string                 `yaml:",omitempty"`
    91  	WatchdogTimeout  string                 `yaml:"watchdog-timeout,omitempty"`
    92  }
    93  
    94  type LayoutMetadata struct {
    95  	Symlink  string `yaml:",omitempty"`
    96  	Bind     string `yaml:",omitempty"`
    97  	BindFile string `yaml:"bind-file,omitempty"`
    98  	Type     string `yaml:",omitempty"`
    99  }
   100  
   101  const defaultNameTemplate = `{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}`
   102  
   103  // Pipe for snapcraft packaging.
   104  type Pipe struct{}
   105  
   106  func (Pipe) String() string                           { return "snapcraft packages" }
   107  func (Pipe) ContinueOnError() bool                    { return true }
   108  func (Pipe) Dependencies(_ *context.Context) []string { return []string{"snapcraft"} }
   109  func (Pipe) Skip(ctx *context.Context) bool {
   110  	return skips.Any(ctx, skips.Snapcraft) || len(ctx.Config.Snapcrafts) == 0
   111  }
   112  
   113  // Default sets the pipe defaults.
   114  func (Pipe) Default(ctx *context.Context) error {
   115  	ids := ids.New("snapcrafts")
   116  	for i := range ctx.Config.Snapcrafts {
   117  		snap := &ctx.Config.Snapcrafts[i]
   118  		if snap.NameTemplate == "" {
   119  			snap.NameTemplate = defaultNameTemplate
   120  		}
   121  		grade, err := tmpl.New(ctx).Apply(snap.Grade)
   122  		if err != nil {
   123  			return err
   124  		}
   125  		snap.Grade = grade
   126  		if snap.Grade == "" {
   127  			snap.Grade = "stable"
   128  		}
   129  		if len(snap.ChannelTemplates) == 0 {
   130  			switch snap.Grade {
   131  			case "devel":
   132  				snap.ChannelTemplates = []string{"edge", "beta"}
   133  			default:
   134  				snap.ChannelTemplates = []string{"edge", "beta", "candidate", "stable"}
   135  			}
   136  		}
   137  		if len(snap.Builds) == 0 {
   138  			for _, b := range ctx.Config.Builds {
   139  				snap.Builds = append(snap.Builds, b.ID)
   140  			}
   141  		}
   142  		ids.Inc(snap.ID)
   143  	}
   144  	return ids.Validate()
   145  }
   146  
   147  // Run the pipe.
   148  func (Pipe) Run(ctx *context.Context) error {
   149  	for _, snap := range ctx.Config.Snapcrafts {
   150  		// TODO: deal with pipe.skip?
   151  		if err := doRun(ctx, snap); err != nil {
   152  			return err
   153  		}
   154  	}
   155  	return nil
   156  }
   157  
   158  func doRun(ctx *context.Context, snap config.Snapcraft) error {
   159  	tpl := tmpl.New(ctx)
   160  	if err := tpl.ApplyAll(
   161  		&snap.Summary,
   162  		&snap.Description,
   163  		&snap.Disable,
   164  	); err != nil {
   165  		return err
   166  	}
   167  	if snap.Disable == "true" {
   168  		return pipe.Skip("configuration is disabled")
   169  	}
   170  	if snap.Summary == "" && snap.Description == "" {
   171  		return pipe.Skip("no summary nor description were provided")
   172  	}
   173  	if snap.Summary == "" {
   174  		return ErrNoSummary
   175  	}
   176  	if snap.Description == "" {
   177  		return ErrNoDescription
   178  	}
   179  	if _, err := exec.LookPath("snapcraft"); err != nil {
   180  		return ErrNoSnapcraft
   181  	}
   182  
   183  	g := semerrgroup.New(ctx.Parallelism)
   184  	for platform, binaries := range ctx.Artifacts.Filter(
   185  		artifact.And(
   186  			artifact.ByGoos("linux"),
   187  			artifact.ByType(artifact.Binary),
   188  			artifact.ByIDs(snap.Builds...),
   189  		),
   190  	).GroupByPlatform() {
   191  		arch := linuxArch(platform)
   192  		if !isValidArch(arch) {
   193  			log.WithField("arch", arch).Warn("ignored unsupported arch")
   194  			continue
   195  		}
   196  		binaries := binaries
   197  		g.Go(func() error {
   198  			return create(ctx, snap, arch, binaries)
   199  		})
   200  	}
   201  	return g.Wait()
   202  }
   203  
   204  func isValidArch(arch string) bool {
   205  	// https://snapcraft.io/docs/architectures
   206  	for _, a := range []string{"s390x", "ppc64el", "arm64", "armhf", "i386", "amd64"} {
   207  		if arch == a {
   208  			return true
   209  		}
   210  	}
   211  	return false
   212  }
   213  
   214  // Publish packages.
   215  func (Pipe) Publish(ctx *context.Context) error {
   216  	snaps := ctx.Artifacts.Filter(artifact.ByType(artifact.PublishableSnapcraft)).List()
   217  	for _, snap := range snaps {
   218  		if err := push(ctx, snap); err != nil {
   219  			return err
   220  		}
   221  	}
   222  	return nil
   223  }
   224  
   225  func create(ctx *context.Context, snap config.Snapcraft, arch string, binaries []*artifact.Artifact) error {
   226  	log := log.WithField("arch", arch)
   227  	folder, err := tmpl.New(ctx).WithArtifact(binaries[0]).Apply(snap.NameTemplate)
   228  	if err != nil {
   229  		return err
   230  	}
   231  
   232  	channels, err := processChannelsTemplates(ctx, snap)
   233  	if err != nil {
   234  		return err
   235  	}
   236  
   237  	// prime is the directory that then will be compressed to make the .snap package.
   238  	folderDir := filepath.Join(ctx.Config.Dist, folder)
   239  	primeDir := filepath.Join(folderDir, "prime")
   240  	metaDir := filepath.Join(primeDir, "meta")
   241  	// #nosec
   242  	if err = os.MkdirAll(metaDir, 0o755); err != nil {
   243  		return err
   244  	}
   245  
   246  	for _, file := range snap.Files {
   247  		if file.Destination == "" {
   248  			file.Destination = file.Source
   249  		}
   250  		if file.Mode == 0 {
   251  			file.Mode = 0o644
   252  		}
   253  		destinationDir := filepath.Join(primeDir, filepath.Dir(file.Destination))
   254  		if err := os.MkdirAll(destinationDir, 0o755); err != nil {
   255  			return fmt.Errorf("failed to create directory '%s': %w", destinationDir, err)
   256  		}
   257  		if err := gio.CopyWithMode(file.Source, filepath.Join(primeDir, file.Destination), os.FileMode(file.Mode)); err != nil {
   258  			return fmt.Errorf("failed to link extra file '%s': %w", file.Source, err)
   259  		}
   260  	}
   261  
   262  	file := filepath.Join(primeDir, "meta", "snap.yaml")
   263  	log.WithField("file", file).Debug("creating snap metadata")
   264  
   265  	metadata := &Metadata{
   266  		Version:       ctx.Version,
   267  		Summary:       snap.Summary,
   268  		Description:   snap.Description,
   269  		Grade:         snap.Grade,
   270  		Confinement:   snap.Confinement,
   271  		Architectures: []string{arch},
   272  		Layout:        map[string]LayoutMetadata{},
   273  		Apps:          map[string]AppMetadata{},
   274  	}
   275  
   276  	if snap.Title != "" {
   277  		metadata.Title = snap.Title
   278  	}
   279  
   280  	if snap.Icon != "" {
   281  		metadata.Icon = snap.Icon
   282  	}
   283  
   284  	if snap.Base != "" {
   285  		metadata.Base = snap.Base
   286  	}
   287  
   288  	if snap.License != "" {
   289  		metadata.License = snap.License
   290  	}
   291  
   292  	metadata.Name = ctx.Config.ProjectName
   293  	if snap.Name != "" {
   294  		metadata.Name = snap.Name
   295  	}
   296  
   297  	for targetPath, layout := range snap.Layout {
   298  		metadata.Layout[targetPath] = LayoutMetadata{
   299  			Symlink:  layout.Symlink,
   300  			Bind:     layout.Bind,
   301  			BindFile: layout.BindFile,
   302  			Type:     layout.Type,
   303  		}
   304  	}
   305  
   306  	// if the user didn't specify any apps then
   307  	// default to the main binary being the command:
   308  	if len(snap.Apps) == 0 {
   309  		name := snap.Name
   310  		if name == "" {
   311  			name = filepath.Base(binaries[0].Name)
   312  		}
   313  		metadata.Apps[name] = AppMetadata{
   314  			Command: filepath.Base(filepath.Base(binaries[0].Name)),
   315  		}
   316  	}
   317  
   318  	for _, binary := range binaries {
   319  		// build the binaries and link resources
   320  		destBinaryPath := filepath.Join(primeDir, filepath.Base(binary.Path))
   321  		log.WithField("src", binary.Path).
   322  			WithField("dst", destBinaryPath).
   323  			Debug("copying")
   324  
   325  		if err = gio.CopyWithMode(binary.Path, destBinaryPath, 0o555); err != nil {
   326  			return fmt.Errorf("failed to copy binary: %w", err)
   327  		}
   328  	}
   329  
   330  	// setup the apps: directive for each binary
   331  	for name, config := range snap.Apps {
   332  		command := name
   333  		if config.Command != "" {
   334  			command = config.Command
   335  		}
   336  
   337  		// TODO: test that the correct binary is used in Command
   338  		// See https://github.com/goreleaser/goreleaser/pull/1449
   339  		appMetadata := AppMetadata{
   340  			Command: strings.TrimSpace(strings.Join([]string{
   341  				command,
   342  				config.Args,
   343  			}, " ")),
   344  			Adapter:          config.Adapter,
   345  			After:            config.After,
   346  			Aliases:          config.Aliases,
   347  			Autostart:        config.Autostart,
   348  			Before:           config.Before,
   349  			BusName:          config.BusName,
   350  			CommandChain:     config.CommandChain,
   351  			CommonID:         config.CommonID,
   352  			Completer:        config.Completer,
   353  			Daemon:           config.Daemon,
   354  			Desktop:          config.Desktop,
   355  			Environment:      config.Environment,
   356  			Extensions:       config.Extensions,
   357  			InstallMode:      config.InstallMode,
   358  			Passthrough:      config.Passthrough,
   359  			Plugs:            config.Plugs,
   360  			PostStopCommand:  config.PostStopCommand,
   361  			RefreshMode:      config.RefreshMode,
   362  			ReloadCommand:    config.ReloadCommand,
   363  			RestartCondition: config.RestartCondition,
   364  			RestartDelay:     config.RestartDelay,
   365  			Slots:            config.Slots,
   366  			Sockets:          config.Sockets,
   367  			StartTimeout:     config.StartTimeout,
   368  			StopCommand:      config.StopCommand,
   369  			StopMode:         config.StopMode,
   370  			StopTimeout:      config.StopTimeout,
   371  			Timer:            config.Timer,
   372  			WatchdogTimeout:  config.WatchdogTimeout,
   373  		}
   374  
   375  		if config.Completer != "" {
   376  			destCompleterPath := filepath.Join(primeDir, config.Completer)
   377  			if err := os.MkdirAll(filepath.Dir(destCompleterPath), 0o755); err != nil {
   378  				return fmt.Errorf("failed to create folder: %w", err)
   379  			}
   380  			log.WithField("src", config.Completer).
   381  				WithField("dst", destCompleterPath).
   382  				Debug("copy")
   383  
   384  			if err := gio.CopyWithMode(config.Completer, destCompleterPath, 0o644); err != nil {
   385  				return fmt.Errorf("failed to copy completer: %w", err)
   386  			}
   387  
   388  			appMetadata.Completer = config.Completer
   389  		}
   390  
   391  		metadata.Apps[name] = appMetadata
   392  		metadata.Assumes = snap.Assumes
   393  		metadata.Hooks = snap.Hooks
   394  		metadata.Plugs = snap.Plugs
   395  	}
   396  
   397  	out, err := yaml.Marshal(metadata)
   398  	if err != nil {
   399  		return err
   400  	}
   401  
   402  	log.WithField("file", file).Debugf("writing metadata file")
   403  	if err = os.WriteFile(file, out, 0o644); err != nil { //nolint: gosec
   404  		return err
   405  	}
   406  
   407  	snapFile := filepath.Join(ctx.Config.Dist, folder+".snap")
   408  	log.WithField("snap", snapFile).Info("creating")
   409  	/* #nosec */
   410  	cmd := exec.CommandContext(ctx, "snapcraft", "pack", primeDir, "--output", snapFile)
   411  	if out, err = cmd.CombinedOutput(); err != nil {
   412  		return fmt.Errorf("failed to generate snap package: %w: %s", err, string(out))
   413  	}
   414  	if !snap.Publish {
   415  		return nil
   416  	}
   417  	ctx.Artifacts.Add(&artifact.Artifact{
   418  		Type:    artifact.PublishableSnapcraft,
   419  		Name:    folder + ".snap",
   420  		Path:    snapFile,
   421  		Goos:    binaries[0].Goos,
   422  		Goarch:  binaries[0].Goarch,
   423  		Goarm:   binaries[0].Goarm,
   424  		Goamd64: binaries[0].Goamd64,
   425  		Extra: map[string]interface{}{
   426  			releasesExtra: channels,
   427  		},
   428  	})
   429  	return nil
   430  }
   431  
   432  const (
   433  	reviewWaitMsg  = `Waiting for previous upload(s) to complete their review process.`
   434  	humanReviewMsg = `A human will soon review your snap`
   435  	needsReviewMsg = `(NEEDS REVIEW)`
   436  )
   437  
   438  func push(ctx *context.Context, snap *artifact.Artifact) error {
   439  	log := log.WithField("snap", snap.Name)
   440  	releases := artifact.ExtraOr(*snap, releasesExtra, []string{})
   441  	/* #nosec */
   442  	cmd := exec.CommandContext(ctx, "snapcraft", "upload", "--release="+strings.Join(releases, ","), snap.Path)
   443  	log.WithField("args", cmd.Args).Info("pushing snap")
   444  	if out, err := cmd.CombinedOutput(); err != nil {
   445  		if strings.Contains(string(out), reviewWaitMsg) || strings.Contains(string(out), humanReviewMsg) || strings.Contains(string(out), needsReviewMsg) {
   446  			log.Warn(reviewWaitMsg)
   447  		} else {
   448  			return fmt.Errorf("failed to push %s package: %w: %s", snap.Path, err, string(out))
   449  		}
   450  	}
   451  	snap.Type = artifact.Snapcraft
   452  	ctx.Artifacts.Add(snap)
   453  	return nil
   454  }
   455  
   456  func processChannelsTemplates(ctx *context.Context, snap config.Snapcraft) ([]string, error) {
   457  	// nolint:prealloc
   458  	var channels []string
   459  	for _, channeltemplate := range snap.ChannelTemplates {
   460  		channel, err := tmpl.New(ctx).Apply(channeltemplate)
   461  		if err != nil {
   462  			return nil, fmt.Errorf("failed to execute channel template '%s': %w", err, err)
   463  		}
   464  		if channel == "" {
   465  			continue
   466  		}
   467  
   468  		channels = append(channels, channel)
   469  	}
   470  
   471  	return channels, nil
   472  }
   473  
   474  var archToSnap = map[string]string{
   475  	"386":     "i386",
   476  	"arm":     "armhf",
   477  	"arm6":    "armhf",
   478  	"arm7":    "armhf",
   479  	"ppc64le": "ppc64el",
   480  }
   481  
   482  // TODO: write tests for this
   483  func linuxArch(key string) string {
   484  	// XXX: list of all linux arches: `go tool dist list | grep linux`
   485  	arch := strings.TrimPrefix(key, "linux")
   486  	for _, suffix := range []string{
   487  		"hardfloat",
   488  		"softfloat",
   489  		"v1",
   490  		"v2",
   491  		"v3",
   492  		"v4",
   493  	} {
   494  		arch = strings.TrimSuffix(arch, suffix)
   495  	}
   496  
   497  	if got, ok := archToSnap[arch]; ok {
   498  		return got
   499  	}
   500  
   501  	return arch
   502  }