diff --git a/cmd/healthcheck.go b/cmd/healthcheck.go new file mode 100644 index 000000000..2af4a083b --- /dev/null +++ b/cmd/healthcheck.go @@ -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 +} diff --git a/cmd/healthcheck_test.go b/cmd/healthcheck_test.go new file mode 100644 index 000000000..eaee260a3 --- /dev/null +++ b/cmd/healthcheck_test.go @@ -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()) +} diff --git a/cmd/root.go b/cmd/root.go index 96ee18464..c8eb63aa2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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, diff --git a/cmd/testdata/missing_tool.yml b/cmd/testdata/missing_tool.yml new file mode 100644 index 000000000..0860d8092 --- /dev/null +++ b/cmd/testdata/missing_tool.yml @@ -0,0 +1,2 @@ +signs: + - cmd: cosignd diff --git a/internal/pipe/chocolatey/chocolatey.go b/internal/pipe/chocolatey/chocolatey.go index f7c57bb23..24b5b84fa 100644 --- a/internal/pipe/chocolatey/chocolatey.go +++ b/internal/pipe/chocolatey/chocolatey.go @@ -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 { diff --git a/internal/pipe/chocolatey/chocolatey_test.go b/internal/pipe/chocolatey/chocolatey_test.go index 7e674e114..0291551cb 100644 --- a/internal/pipe/chocolatey/chocolatey_test.go +++ b/internal/pipe/chocolatey/chocolatey_test.go @@ -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) } diff --git a/internal/pipe/docker/docker.go b/internal/pipe/docker/docker.go index 60fe97e58..3ac2c6458 100644 --- a/internal/pipe/docker/docker.go +++ b/internal/pipe/docker/docker.go @@ -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") diff --git a/internal/pipe/docker/docker_test.go b/internal/pipe/docker/docker_test.go index c77870c78..ca0527592 100644 --- a/internal/pipe/docker/docker_test.go +++ b/internal/pipe/docker/docker_test.go @@ -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)) +} diff --git a/internal/pipe/docker/manifest.go b/internal/pipe/docker/manifest.go index bb1caa97a..d73d87acb 100644 --- a/internal/pipe/docker/manifest.go +++ b/internal/pipe/docker/manifest.go @@ -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") diff --git a/internal/pipe/sbom/sbom.go b/internal/pipe/sbom/sbom.go index 12649c450..3e168979c 100644 --- a/internal/pipe/sbom/sbom.go +++ b/internal/pipe/sbom/sbom.go @@ -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") diff --git a/internal/pipe/sbom/sbom_test.go b/internal/pipe/sbom/sbom_test.go index a2e1df022..607c78495 100644 --- a/internal/pipe/sbom/sbom_test.go +++ b/internal/pipe/sbom/sbom_test.go @@ -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)) +} diff --git a/internal/pipe/sign/sign.go b/internal/pipe/sign/sign.go index d384f6886..dc84c3d66 100644 --- a/internal/pipe/sign/sign.go +++ b/internal/pipe/sign/sign.go @@ -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") diff --git a/internal/pipe/sign/sign_docker.go b/internal/pipe/sign/sign_docker.go index fed0c7c2c..0bb45ca21 100644 --- a/internal/pipe/sign/sign_docker.go +++ b/internal/pipe/sign/sign_docker.go @@ -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") diff --git a/internal/pipe/sign/sign_docker_test.go b/internal/pipe/sign/sign_docker_test.go index a707abb0e..81eeef84e 100644 --- a/internal/pipe/sign/sign_docker_test.go +++ b/internal/pipe/sign/sign_docker_test.go @@ -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)) +} diff --git a/internal/pipe/sign/sign_test.go b/internal/pipe/sign/sign_test.go index 5e0e67ee4..fee193067 100644 --- a/internal/pipe/sign/sign_test.go +++ b/internal/pipe/sign/sign_test.go @@ -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)) +} diff --git a/internal/pipe/snapcraft/snapcraft.go b/internal/pipe/snapcraft/snapcraft.go index f86158b9b..c9f661cd2 100644 --- a/internal/pipe/snapcraft/snapcraft.go +++ b/internal/pipe/snapcraft/snapcraft.go @@ -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 { diff --git a/internal/pipe/snapcraft/snapcraft_test.go b/internal/pipe/snapcraft/snapcraft_test.go index 9e024b499..23bfd740a 100644 --- a/internal/pipe/snapcraft/snapcraft_test.go +++ b/internal/pipe/snapcraft/snapcraft_test.go @@ -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) diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go new file mode 100644 index 000000000..19adb7ab9 --- /dev/null +++ b/pkg/healthcheck/healthcheck.go @@ -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"} } diff --git a/pkg/healthcheck/healthcheck_test.go b/pkg/healthcheck/healthcheck_test.go new file mode 100644 index 000000000..06865d2b7 --- /dev/null +++ b/pkg/healthcheck/healthcheck_test.go @@ -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()) +}