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 }