1
0
mirror of https://github.com/goreleaser/goreleaser.git synced 2024-12-31 01:53:50 +02:00

feat: add healthcheck cmd (#3826)

here's an idea: `goreleaser healthcheck`

It'll check if the needed dependencies (docker, git, etc) are available
in the path... this way users can preemptively run it before releasing
or to debug issues.

What do you think?

Here's how it looks like:

<img width="1007" alt="CleanShot 2023-03-02 at 23 24 26@2x"
src="https://user-images.githubusercontent.com/245435/222615682-d9cd0733-d900-43d1-9166-23b2be589b3a.png">

---------

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>
This commit is contained in:
Carlos Alexandro Becker 2023-03-03 09:50:15 -03:00 committed by GitHub
parent b6dd26c091
commit 874d698564
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 316 additions and 5 deletions

107
cmd/healthcheck.go Normal file
View File

@ -0,0 +1,107 @@
package cmd
import (
"fmt"
"io"
"os/exec"
"sync"
"github.com/caarlos0/ctrlc"
"github.com/caarlos0/log"
"github.com/charmbracelet/lipgloss"
"github.com/goreleaser/goreleaser/internal/middleware/skip"
"github.com/goreleaser/goreleaser/internal/pipe/defaults"
"github.com/goreleaser/goreleaser/pkg/context"
"github.com/goreleaser/goreleaser/pkg/healthcheck"
"github.com/spf13/cobra"
)
type healthcheckCmd struct {
cmd *cobra.Command
config string
quiet bool
deprecated bool
}
func newHealthcheckCmd() *healthcheckCmd {
root := &healthcheckCmd{}
cmd := &cobra.Command{
Use: "healthcheck",
Aliases: []string{"hc"},
Short: "Checks if needed tools are installed",
SilenceUsage: true,
SilenceErrors: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if root.quiet {
log.Log = log.New(io.Discard)
}
cfg, err := loadConfig(root.config)
if err != nil {
return err
}
ctx := context.New(cfg)
ctx.Deprecated = root.deprecated
if err := ctrlc.Default.Run(ctx, func() error {
log.Info(boldStyle.Render("checking tools..."))
err := defaults.Pipe{}.Run(ctx)
if err != nil {
return err
}
log.IncreasePadding()
defer log.ResetPadding()
var errs []error
for _, hc := range healthcheck.Healthcheckers {
_ = skip.Maybe(hc, func(ctx *context.Context) error {
for _, tool := range hc.Dependencies(ctx) {
if err := checkPath(tool); err != nil {
errs = append(errs, err)
}
}
return nil
})(ctx)
}
if len(errs) == 0 {
return nil
}
return fmt.Errorf("one or more needed tools are not present")
}); err != nil {
return err
}
log.Infof(boldStyle.Render("done!"))
return nil
},
}
cmd.Flags().StringVarP(&root.config, "config", "f", "", "Configuration file")
cmd.Flags().BoolVarP(&root.quiet, "quiet", "q", false, "Quiet mode: no output")
cmd.Flags().BoolVar(&root.deprecated, "deprecated", false, "Force print the deprecation message - tests only")
_ = cmd.Flags().MarkHidden("deprecated")
root.cmd = cmd
return root
}
var toolsChecked = &sync.Map{}
func checkPath(tool string) error {
if _, ok := toolsChecked.LoadOrStore(tool, true); ok {
return nil
}
if _, err := exec.LookPath(tool); err != nil {
st := log.Styles[log.ErrorLevel]
log.Warnf("%s %s - %s", st.Render("⚠"), codeStyle.Render(tool), st.Render("not present in path"))
return err
}
st := lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
log.Infof("%s %s", st.Render("✓"), codeStyle.Render(tool))
return nil
}

31
cmd/healthcheck_test.go Normal file
View File

@ -0,0 +1,31 @@
package cmd
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestHealthcheckSystem(t *testing.T) {
cmd := newHealthcheckCmd()
cmd.cmd.SetArgs([]string{"-f", "testdata/good.yml"})
require.NoError(t, cmd.cmd.Execute())
}
func TestHealthcheckConfigThatDoesNotExist(t *testing.T) {
cmd := newHealthcheckCmd()
cmd.cmd.SetArgs([]string{"-f", "testdata/nope.yml"})
require.EqualError(t, cmd.cmd.Execute(), "open testdata/nope.yml: no such file or directory")
}
func TestHealthcheckMissingTool(t *testing.T) {
cmd := newHealthcheckCmd()
cmd.cmd.SetArgs([]string{"-f", "testdata/missing_tool.yml"})
require.EqualError(t, cmd.cmd.Execute(), "one or more needed tools are not present")
}
func TestHealthcheckQuier(t *testing.T) {
cmd := newHealthcheckCmd()
cmd.cmd.SetArgs([]string{"-f", "testdata/good.yml", "--quiet"})
require.NoError(t, cmd.cmd.Execute())
}

View File

@ -12,7 +12,10 @@ import (
cobracompletefig "github.com/withfig/autocomplete-tools/integrations/cobra"
)
var boldStyle = lipgloss.NewStyle().Bold(true)
var (
boldStyle = lipgloss.NewStyle().Bold(true)
codeStyle = lipgloss.NewStyle().Italic(true)
)
func Execute(version string, exit func(int), args []string) {
newRootCmd(version, exit).Execute(args)
@ -79,6 +82,7 @@ Check out our website for more information, examples and documentation: https://
newBuildCmd().cmd,
newReleaseCmd().cmd,
newCheckCmd().cmd,
newHealthcheckCmd().cmd,
newInitCmd().cmd,
newDocsCmd().cmd,
newManCmd().cmd,

2
cmd/testdata/missing_tool.yml vendored Normal file
View File

@ -0,0 +1,2 @@
signs:
- cmd: cosignd

View File

@ -30,8 +30,9 @@ var cmd cmder = stdCmd{}
// Pipe for chocolatey packaging.
type Pipe struct{}
func (Pipe) String() string { return "chocolatey packages" }
func (Pipe) Skip(ctx *context.Context) bool { return len(ctx.Config.Chocolateys) == 0 }
func (Pipe) String() string { return "chocolatey packages" }
func (Pipe) Skip(ctx *context.Context) bool { return len(ctx.Config.Chocolateys) == 0 }
func (Pipe) Dependencies(ctx *context.Context) []string { return []string{"choco"} }
// Default sets the pipe defaults.
func (Pipe) Default(ctx *context.Context) error {

View File

@ -301,6 +301,10 @@ func TestPublish(t *testing.T) {
}
}
func TestDependencies(t *testing.T) {
require.Equal(t, []string{"choco"}, Pipe{}.Dependencies(nil))
}
type fakeCmd struct {
execFn func() ([]byte, error)
}

View File

@ -31,6 +31,18 @@ type Pipe struct{}
func (Pipe) String() string { return "docker images" }
func (Pipe) Skip(ctx *context.Context) bool { return len(ctx.Config.Dockers) == 0 || ctx.SkipDocker }
func (Pipe) Dependencies(ctx *context.Context) []string {
var cmds []string
for _, s := range ctx.Config.Dockers {
switch s.Use {
case useDocker, useBuildx:
cmds = append(cmds, "docker")
// TODO: how to check if buildx is installed
}
}
return cmds
}
// Default sets the pipe defaults.
func (Pipe) Default(ctx *context.Context) error {
ids := ids.New("dockers")

View File

@ -1397,3 +1397,20 @@ func TestWithDigest(t *testing.T) {
})
}
}
func TestDependencies(t *testing.T) {
ctx := testctx.NewWithCfg(config.Project{
Dockers: []config.Docker{
{Use: useBuildx},
{Use: useDocker},
{Use: "nope"},
},
DockerManifests: []config.DockerManifest{
{Use: useBuildx},
{Use: useDocker},
{Use: "nope"},
},
})
require.Equal(t, []string{"docker", "docker"}, Pipe{}.Dependencies(ctx))
require.Equal(t, []string{"docker", "docker"}, ManifestPipe{}.Dependencies(ctx))
}

View File

@ -24,6 +24,18 @@ func (ManifestPipe) Skip(ctx *context.Context) bool {
return len(ctx.Config.DockerManifests) == 0 || ctx.SkipDocker
}
func (ManifestPipe) Dependencies(ctx *context.Context) []string {
var cmds []string
for _, s := range ctx.Config.DockerManifests {
switch s.Use {
case useDocker, useBuildx:
cmds = append(cmds, "docker")
// TODO: check buildx
}
}
return cmds
}
// Default sets the pipe defaults.
func (ManifestPipe) Default(ctx *context.Context) error {
ids := ids.New("docker_manifests")

View File

@ -31,6 +31,14 @@ func (Pipe) Skip(ctx *context.Context) bool {
return ctx.SkipSBOMCataloging || len(ctx.Config.SBOMs) == 0
}
func (Pipe) Dependencies(ctx *context.Context) []string {
var cmds []string
for _, s := range ctx.Config.SBOMs {
cmds = append(cmds, s.Cmd)
}
return cmds
}
// Default sets the Pipes defaults.
func (Pipe) Default(ctx *context.Context) error {
ids := ids.New("sboms")

View File

@ -750,3 +750,13 @@ func Test_templateNames(t *testing.T) {
})
}
}
func TestDependencies(t *testing.T) {
ctx := testctx.NewWithCfg(config.Project{
SBOMs: []config.SBOM{
{Cmd: "syft"},
{Cmd: "foobar"},
},
})
require.Equal(t, []string{"syft", "foobar"}, Pipe{}.Dependencies(ctx))
}

View File

@ -27,6 +27,14 @@ type Pipe struct{}
func (Pipe) String() string { return "signing artifacts" }
func (Pipe) Skip(ctx *context.Context) bool { return ctx.SkipSign || len(ctx.Config.Signs) == 0 }
func (Pipe) Dependencies(ctx *context.Context) []string {
var cmds []string
for _, s := range ctx.Config.Signs {
cmds = append(cmds, s.Cmd)
}
return cmds
}
// Default sets the Pipes defaults.
func (Pipe) Default(ctx *context.Context) error {
ids := ids.New("signs")

View File

@ -19,6 +19,14 @@ func (DockerPipe) Skip(ctx *context.Context) bool {
return ctx.SkipSign || len(ctx.Config.DockerSigns) == 0
}
func (DockerPipe) Dependencies(ctx *context.Context) []string {
var cmds []string
for _, s := range ctx.Config.DockerSigns {
cmds = append(cmds, s.Cmd)
}
return cmds
}
// Default sets the Pipes defaults.
func (DockerPipe) Default(ctx *context.Context) error {
ids := ids.New("docker_signs")

View File

@ -228,3 +228,13 @@ func TestDockerSkip(t *testing.T) {
require.False(t, DockerPipe{}.Skip(ctx))
})
}
func TestDockerDependencies(t *testing.T) {
ctx := testctx.NewWithCfg(config.Project{
DockerSigns: []config.Sign{
{Cmd: "cosign"},
{Cmd: "gpg2"},
},
})
require.Equal(t, []string{"cosign", "gpg2"}, DockerPipe{}.Dependencies(ctx))
}

View File

@ -723,3 +723,13 @@ func TestSkip(t *testing.T) {
require.False(t, Pipe{}.Skip(ctx))
})
}
func TestDependencies(t *testing.T) {
ctx := testctx.NewWithCfg(config.Project{
Signs: []config.Sign{
{Cmd: "cosign"},
{Cmd: "gpg2"},
},
})
require.Equal(t, []string{"cosign", "gpg2"}, Pipe{}.Dependencies(ctx))
}

View File

@ -99,8 +99,9 @@ const defaultNameTemplate = `{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arc
// Pipe for snapcraft packaging.
type Pipe struct{}
func (Pipe) String() string { return "snapcraft packages" }
func (Pipe) Skip(ctx *context.Context) bool { return len(ctx.Config.Snapcrafts) == 0 }
func (Pipe) String() string { return "snapcraft packages" }
func (Pipe) Skip(ctx *context.Context) bool { return len(ctx.Config.Snapcrafts) == 0 }
func (Pipe) Dependencies(ctx *context.Context) []string { return []string{"snapcraft"} }
// Default sets the pipe defaults.
func (Pipe) Default(ctx *context.Context) error {

View File

@ -697,6 +697,15 @@ func TestSkip(t *testing.T) {
})
}
func TestDependencies(t *testing.T) {
ctx := testctx.NewWithCfg(config.Project{
Snapcrafts: []config.Snapcraft{
{},
},
})
require.Equal(t, []string{"snapcraft"}, Pipe{}.Dependencies(ctx))
}
func requireEqualFileConents(tb testing.TB, a, b string) {
tb.Helper()
eq, err := gio.EqualFileContents(a, b)

View File

@ -0,0 +1,40 @@
// Package healthcheck checks for missing binaries that the user needs to
// install.
package healthcheck
import (
"fmt"
"github.com/goreleaser/goreleaser/internal/pipe/chocolatey"
"github.com/goreleaser/goreleaser/internal/pipe/docker"
"github.com/goreleaser/goreleaser/internal/pipe/sbom"
"github.com/goreleaser/goreleaser/internal/pipe/sign"
"github.com/goreleaser/goreleaser/internal/pipe/snapcraft"
"github.com/goreleaser/goreleaser/pkg/context"
)
// Healthchecker should be implemented by pipes that want checks.
type Healthchecker interface {
fmt.Stringer
// Dependencies return the binaries of the dependencies needed.
Dependencies(ctx *context.Context) []string
}
// Healthcheckers is the list of healthchekers.
// nolint: gochecknoglobals
var Healthcheckers = []Healthchecker{
system{},
snapcraft.Pipe{},
sign.Pipe{},
sign.DockerPipe{},
sbom.Pipe{},
docker.Pipe{},
docker.ManifestPipe{},
chocolatey.Pipe{},
}
type system struct{}
func (system) String() string { return "system" }
func (system) Dependencies(ctx *context.Context) []string { return []string{"git", "go"} }

View File

@ -0,0 +1,17 @@
package healthcheck
import (
"testing"
"github.com/goreleaser/goreleaser/internal/testctx"
"github.com/stretchr/testify/require"
)
func TestDependencies(t *testing.T) {
ctx := testctx.New()
require.Equal(t, []string{"git", "go"}, system{}.Dependencies(ctx))
}
func TestStringer(t *testing.T) {
require.NotEmpty(t, system{}.String())
}