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 }