From 2a8331748671a9051b6e40581e33c584d6cb16cc Mon Sep 17 00:00:00 2001 From: Ben Doerr Date: Thu, 29 Aug 2024 15:23:55 -0400 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20cli:=20Improves=20structur?= =?UTF-8?q?e,=20the=20UI=20&=20error=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .golangci.yml | 2 +- cmd/main.go | 82 ++++++++++----------------- go.mod | 6 ++ go.sum | 13 +++++ pkg/github/latest.go | 20 ++----- pkg/tflint/open.go | 30 ++++++---- pkg/tflint/update.go | 131 +++++++++++++++++++++++++++++++++++++++++++ pkg/ui/context.go | 42 ++++++++++++++ pkg/ui/ui.go | 64 +++++++++++++++++++++ 9 files changed, 311 insertions(+), 79 deletions(-) create mode 100644 pkg/tflint/update.go create mode 100644 pkg/ui/context.go create mode 100644 pkg/ui/ui.go diff --git a/.golangci.yml b/.golangci.yml index 895fea8..c6b3360 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -215,7 +215,6 @@ linters: - forbidigo # forbids identifiers - funlen # tool for detection of long functions - gocheckcompilerdirectives # validates go compiler directive comments (//go:) - - gochecknoglobals # checks that no global variables exist - gochecknoinits # checks that no init functions are present in Go code - gochecksumtype # checks exhaustiveness on Go "sum types" - gocognit # computes and checks the cognitive complexity of functions @@ -277,6 +276,7 @@ linters: #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope #- wrapcheck # checks that errors returned from external packages are wrapped #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event + #- gochecknoglobals # checks that no global variables exist ## disabled #- containedctx # detects struct contained context.Context field diff --git a/cmd/main.go b/cmd/main.go index 527aa7b..ce57e32 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,20 +1,21 @@ package main import ( + "context" "os" - "strings" "github.com/alecthomas/kong" - "github.com/hashicorp/hcl/v2/hclwrite" "github.com/spf13/afero" "github.com/bendoerr-terraform-modules/tflint-plugin-version-update/pkg/github" "github.com/bendoerr-terraform-modules/tflint-plugin-version-update/pkg/tflint" + "github.com/bendoerr-terraform-modules/tflint-plugin-version-update/pkg/ui" ) type Config struct { - Freeze bool `name:"freeze"` - Path string `name:"path" arg:"" type:"path"` + Freeze bool `name:"freeze"` + Verbose bool `name:"verbose"` + Path string `name:"path" arg:"" type:"path" optional:"true"` } func main() { @@ -23,66 +24,43 @@ func main() { _ = kong.Parse(&cfg) - tflFile, err := tflint.OpenConfig(afero.Afero{Fs: afero.NewOsFs()}, cfg.Path) - if err != nil { - panic(err) - } + ctx := context.Background() + ctx = ui.ToContext(ctx, ui.NewUI(os.Stdout, cfg.Verbose)) - tflData, err := tflint.NewData(tflFile) + tflFile, err := tflint.OpenConfig(ctx, afero.Afero{Fs: afero.NewOsFs()}, cfg.Path) if err != nil { - panic(err) + if cfg.Verbose { + panic(err) + } + os.Exit(1) } - tflHcl, err := tflData.ParseForRead() - if err != nil { - panic(err) - } + tfl := tflint.NewTFLint(tflFile) - tflHclW, err := tflData.ParseForWrite() + err = tfl.ParseHCL(ctx) if err != nil { - panic(err) + if cfg.Verbose { + panic(err) + } + os.Exit(1) } - plugins, err := tflint.FindPluginVersions(tflHcl) + err = tfl.UpdatePlugins(ctx, cfg.Freeze, github.LatestVersion) if err != nil { - panic(err) - } - - runUpdate(plugins, cfg, tflHclW) - - _, _ = tflHclW.WriteTo(os.Stdout) -} - -func runUpdate(plugins []*tflint.PluginConfig, cfg Config, tflHclW *hclwrite.File) { - for _, plugin := range plugins { - latestVersion, err := github.LatestVersion(plugin.SourceOwner, plugin.SourceRepo) - if err != nil { + if cfg.Verbose { panic(err) } + os.Exit(1) + } - if cfg.Freeze { - if plugin.Version == latestVersion.ReleaseSHA { - continue - } - } else { - if plugin.Version == latestVersion.ReleaseTag || "v"+plugin.Version == latestVersion.ReleaseTag { - continue - } - } - - if cfg.Freeze { - err = tflint.UpdatePluginVersion(plugin.Name, latestVersion.ReleaseSHA, latestVersion.ReleaseTag, tflHclW) - if err != nil { - panic(err) - } - } else { - // Stylistically tflint drops the 'v' in their documentation, - // so we'll follow that as well. - version := strings.TrimPrefix(latestVersion.ReleaseTag, "v") - err = tflint.UpdatePluginVersion(plugin.Name, version, "", tflHclW) - if err != nil { - panic(err) - } + err = tfl.Write(ctx) + if err != nil { + if cfg.Verbose { + panic(err) } + os.Exit(1) } + + ui.Stop(ctx) + ui.Info(ctx, "✨ Done") } diff --git a/go.mod b/go.mod index 86682e7..52c0886 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.23.0 require ( github.com/alecthomas/kong v0.9.0 + github.com/briandowns/spinner v1.23.1 + github.com/fatih/color v1.7.0 github.com/hashicorp/hcl/v2 v2.22.0 github.com/spf13/afero v1.11.0 github.com/zclconf/go-cty v1.15.0 @@ -13,9 +15,13 @@ require ( github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/mattn/go-colorable v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.8 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/term v0.1.0 // indirect golang.org/x/text v0.17.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect ) diff --git a/go.sum b/go.sum index 28fef15..57aaacc 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,12 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= +github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -18,6 +22,10 @@ github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul1 github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -30,6 +38,11 @@ golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= diff --git a/pkg/github/latest.go b/pkg/github/latest.go index f79e778..c235137 100644 --- a/pkg/github/latest.go +++ b/pkg/github/latest.go @@ -8,12 +8,6 @@ import ( "time" ) -type Latest struct { - ReleaseTag string - ReleaseSHA string - ReleaseDescription string -} - type latestResponse struct { TagName string `json:"tag_name"` Description string `json:"body"` @@ -81,20 +75,16 @@ func getTag(owner, repo, tag string) (*tagResponse, error) { return result, nil } -func LatestVersion(owner, repo string) (*Latest, error) { +func LatestVersion(owner, repo string) (string, string, string, error) { release, err := getLatest(owner, repo) if err != nil { - return nil, err + return "", "", "", err } - tag, err := getTag(owner, repo, release.TagName) + tagV, err := getTag(owner, repo, release.TagName) if err != nil { - return nil, err + return "", "", "", err } - return &Latest{ - ReleaseTag: release.TagName, - ReleaseSHA: tag.Object.SHA, - ReleaseDescription: release.Description, - }, nil + return release.TagName, tagV.Object.SHA, release.Description, nil } diff --git a/pkg/tflint/open.go b/pkg/tflint/open.go index f6aa0e3..b0ace3a 100644 --- a/pkg/tflint/open.go +++ b/pkg/tflint/open.go @@ -1,12 +1,14 @@ package tflint import ( + "context" "errors" "fmt" - "log" "os" "github.com/spf13/afero" + + "github.com/bendoerr-terraform-modules/tflint-plugin-version-update/pkg/ui" ) // OpenConfig loads a TFLint config file following the logic from tflint except @@ -23,13 +25,16 @@ import ( // // For 1 and 2, if the file does not exist, an error will be returned immediately. // If 3 fails then an error will be returned. -func OpenConfig(fs afero.Afero, file string) (afero.File, error) { +func OpenConfig(ctx context.Context, fs afero.Afero, file string) (afero.File, error) { + ui.Update(ctx, "🔧 Finding config") + // Load the file passed by the --config option if file != "" { - log.Printf("[INFO] Load config: %s", file) - f, err := fs.Open(file) + ui.Info(ctx, "🔧 Using provided config "+file) + f, err := fs.OpenFile(file, os.O_RDWR, os.ModePerm) if err != nil { - return nil, fmt.Errorf("unable to open file='%s': %w", file, err) + ui.Error(ctx, "🚨 Couldn't open provided config file!") + return nil, fmt.Errorf("unable to open config='%s': %w", file, err) } return f, nil } @@ -37,9 +42,10 @@ func OpenConfig(fs afero.Afero, file string) (afero.File, error) { // Load the file set by the environment variable envFile := os.Getenv("TFLINT_CONFIG_FILE") if envFile != "" { - log.Printf("[INFO] Load config: %s", envFile) + ui.Info(ctx, "🔧 Using env.TFLINT_CONFIG_FILE "+envFile) f, err := fs.Open(envFile) if err != nil { + ui.Error(ctx, "🚨 Couldn't open env.TFLINT_CONFIG_FILE") return nil, fmt.Errorf("unable to open TFLINT_CONFIG_FILE='%s': %w", envFile, err) } return f, nil @@ -47,11 +53,13 @@ func OpenConfig(fs afero.Afero, file string) (afero.File, error) { // Load the default config file var defaultConfigFile = ".tflint.hcl" - log.Printf("[INFO] Load default config: %s", defaultConfigFile) - if f, err := fs.Open(defaultConfigFile); err == nil { - return f, nil + ui.Info(ctx, "🔧 Using default config "+defaultConfigFile) + f, err := fs.Open(defaultConfigFile) + if err != nil { + ui.Error(ctx, "🚨 Couldn't open default config") + return nil, errors.New("no config file found") } - log.Printf("[INFO] Default config not found") - return nil, errors.New("no config file found") + ui.Update(ctx, "") + return f, nil } diff --git a/pkg/tflint/update.go b/pkg/tflint/update.go new file mode 100644 index 0000000..3226cdf --- /dev/null +++ b/pkg/tflint/update.go @@ -0,0 +1,131 @@ +package tflint + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/spf13/afero" + + "github.com/bendoerr-terraform-modules/tflint-plugin-version-update/pkg/ui" +) + +type TFLint struct { + file afero.File + hclread *hcl.File + hclwrite *hclwrite.File + plugins []*PluginConfig + updated bool +} + +func NewTFLint(file afero.File) *TFLint { + return &TFLint{file: file} +} + +func (tfl *TFLint) ParseHCL(ctx context.Context) error { + ui.Update(ctx, "🔍️ Parsing HCL") + + data, err := NewData(tfl.file) + if err != nil { + ui.Error(ctx, "🚨 Failed to read HCL config!") + return err + } + + tfl.hclread, err = data.ParseForRead() + if err != nil { + ui.Error(ctx, "🚨 Failed to parse HCL config!") + return err + } + + tfl.hclwrite, err = data.ParseForWrite() + if err != nil { + ui.Error(ctx, "🚨 Failed to parse HCL config!") + return err + } + + tfl.plugins, err = FindPluginVersions(tfl.hclread) + if err != nil { + ui.Error(ctx, "🚨 Failed to find plugin config!") + return err + } + + ui.Update(ctx, "") + return nil +} + +type LatestVersionFunc func(owner, repo string) (tag, sha, desc string, err error) + +func (tfl *TFLint) UpdatePlugins(ctx context.Context, freezeSHA bool, latestVersionFunc LatestVersionFunc) error { + if tfl.plugins == nil { + panic("programmer error: parse the HCL first") + } + + ui.Update(ctx, "🚀 Updating plugins to the latest versions") + + for _, plugin := range tfl.plugins { + latestTag, latestSHA, _, err := latestVersionFunc(plugin.SourceOwner, plugin.SourceRepo) + if err != nil { + ui.Error(ctx, "🚨 Failed get latest plugin version!") + return err + } + + if isUpToDate(plugin.Version, latestTag, latestSHA, freezeSHA) { + ui.Info(ctx, fmt.Sprintf("✅ %s/%s@%s", plugin.SourceOwner, plugin.SourceRepo, plugin.Version)) + continue + } + + var newVersion string + var newComment string + if freezeSHA { + newVersion = latestSHA + newComment = latestTag + } else { + // Stylistically tflint drops the 'v' in their documentation, + // so we'll follow that as well. + newVersion = strings.TrimPrefix(latestTag, "v") + } + + err = UpdatePluginVersion(plugin.Name, newVersion, newComment, tfl.hclwrite) + if err != nil { + ui.Error(ctx, "🚨 Failed to update the HCL plugin version") + return err + } + tfl.updated = true + + ui.Info(ctx, fmt.Sprintf("⬆️ %s/%s@%s → %s", plugin.SourceOwner, plugin.SourceRepo, plugin.Version, newVersion)) + } + + ui.Update(ctx, "") + return nil +} + +func (tfl *TFLint) Write(ctx context.Context) error { + if tfl.hclwrite == nil { + panic("programmer error: parse the HCL first") + } + + if !tfl.updated { + return nil + } + + ui.Update(ctx, "✏️ Writing new TFLint config") + + _ = tfl.file.Truncate(0) + _, _ = tfl.file.Seek(0, 0) + + _, err := tfl.hclwrite.WriteTo(tfl.file) + if err != nil { + ui.Error(ctx, "🚨 Failed to write new TFLint config!") + return err + } + + ui.Update(ctx, "") + return nil +} + +func isUpToDate(currentVersion, latestTag, latestSHA string, useSHA bool) bool { + return (useSHA && currentVersion == latestSHA) || + (!useSHA && (currentVersion == latestTag || "v"+currentVersion == latestTag)) +} diff --git a/pkg/ui/context.go b/pkg/ui/context.go new file mode 100644 index 0000000..db16f6f --- /dev/null +++ b/pkg/ui/context.go @@ -0,0 +1,42 @@ +package ui + +import ( + "context" +) + +type uiCtxKey struct{} + +func ToContext(ctx context.Context, ui *UI) context.Context { + return context.WithValue(ctx, uiCtxKey{}, ui) +} + +func FromContext(ctx context.Context) (*UI, bool) { + if value := ctx.Value(uiCtxKey{}); value != nil { + return value.(*UI), true + } + return nil, false +} + +func Update(ctx context.Context, msg string) { + if ui, ok := FromContext(ctx); ok { + ui.Update(msg) + } +} + +func Info(ctx context.Context, msg string) { + if ui, ok := FromContext(ctx); ok { + ui.Info(msg) + } +} + +func Error(ctx context.Context, msg string) { + if ui, ok := FromContext(ctx); ok { + ui.Error(msg) + } +} + +func Stop(ctx context.Context) { + if ui, ok := FromContext(ctx); ok { + ui.Stop() + } +} diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go new file mode 100644 index 0000000..cbf8514 --- /dev/null +++ b/pkg/ui/ui.go @@ -0,0 +1,64 @@ +package ui + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/briandowns/spinner" + "github.com/fatih/color" +) + +const spinnerMillis = 200 + +type UI struct { + spinner *spinner.Spinner + output *os.File + verbose bool +} + +var infoFormat = color.New(color.FgHiBlue).Sprint +var erroFormat = color.New(color.FgRed).Sprint + +func NewUI(output *os.File, verbose bool) *UI { + ui := &UI{ + spinner: spinner.New(spinner.CharSets[11], spinnerMillis*time.Millisecond, spinner.WithWriterFile(output)), + output: output, + verbose: verbose, + } + + log.SetOutput(output) + + _ = ui.spinner.Color("white", "bold") + ui.spinner.Prefix = " " + ui.spinner.Start() + + return ui +} + +func (ui *UI) Update(msg string) { + if ui.verbose { + ui.Info(msg) + } else { + ui.spinner.Suffix = " " + msg + } +} + +func (ui *UI) println(msg string) { + ui.spinner.Stop() + fmt.Fprintln(ui.output, msg) + ui.spinner.Start() +} + +func (ui *UI) Info(msg string) { + ui.println(" " + infoFormat(msg)) +} + +func (ui *UI) Error(msg string) { + ui.println(" " + erroFormat(msg)) +} + +func (ui *UI) Stop() { + ui.spinner.Disable() +}