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