From a83f8854fd88b9e006343478c53803470d200803 Mon Sep 17 00:00:00 2001 From: Sylvain Witmeyer Date: Mon, 13 Mar 2023 01:18:43 -0400 Subject: [PATCH] feat: display locals and unused ones new flags --variables and --locals --- Makefile | 6 +- cmd/root.go | 131 ++------------ cmd/root_test.go | 33 ---- go.mod | 14 +- go.sum | 8 + terraform/main.go | 191 ++++++++++++++++++++ terraform/main_test.go | 40 ++++ {cmd => terraform}/testdata/main.tf | 0 {cmd => terraform}/testdata/tf/1/main.tf | 0 {cmd => terraform}/testdata/tf/main.tf | 9 + {cmd => terraform}/testdata/tf/outputs.tf | 0 {cmd => terraform}/testdata/tf/variables.tf | 0 12 files changed, 276 insertions(+), 156 deletions(-) delete mode 100644 cmd/root_test.go create mode 100644 terraform/main.go create mode 100644 terraform/main_test.go rename {cmd => terraform}/testdata/main.tf (100%) rename {cmd => terraform}/testdata/tf/1/main.tf (100%) rename {cmd => terraform}/testdata/tf/main.tf (73%) rename {cmd => terraform}/testdata/tf/outputs.tf (100%) rename {cmd => terraform}/testdata/tf/variables.tf (100%) diff --git a/Makefile b/Makefile index 56360a5..2c01ca2 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ -XC_OS="linux darwin" -XC_ARCH="amd64 arm64" +XC_OS="darwin" +#XC_OS="linux darwin" +XC_ARCH="arm64" +#XC_ARCH="amd64 arm64" XC_PARALLEL="2" BIN="dist" SRC=$(shell find . -name "*.go") diff --git a/cmd/root.go b/cmd/root.go index d6dc51b..a3f5b22 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,18 +2,13 @@ package cmd import ( "fmt" - "github.com/hashicorp/terraform-config-inspect/tfconfig" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "io/fs" - "os" - "path/filepath" - "regexp" + "github.com/sylwit/terraform-cleaner/terraform" ) var rootCmd = &cobra.Command{ Use: "terraform-cleaner ", - Short: "Remove unused variables", + Short: "List variables and locals usage", RunE: rootCmdExec, } @@ -25,133 +20,43 @@ func rootCmdExec(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true fUnusedOnly, _ := cmd.Flags().GetBool("unused-only") + fVariables, _ := cmd.Flags().GetBool("variables") + fLocals, _ := cmd.Flags().GetBool("locals") - modules, err := findTfModules(dir) + dType := terraform.All + if fVariables && !fLocals { + dType = terraform.Variables + } else if !fVariables && fLocals { + dType = terraform.Locals + } + + modules, err := terraform.ListTfModules(dir) if err != nil { return err } for path := range modules { - stats, err := findVariablesUsage(path) + moduleUsage, err := terraform.NewModuleUsage(path) if err != nil { return err } - - if fUnusedOnly { - for name, count := range stats.variables { - if count > 0 { - delete(stats.variables, name) - } - } - if len(stats.variables) == 0 { - continue - } - } - - err = displayModule(path, &stats) + err = moduleUsage.Display(dType, fUnusedOnly) if err != nil { return err } - } - fmt.Printf("%d modules processed", len(modules)) - - return nil -} - -func displayModule(path string, stats *VariablesUsage) error { - fmt.Printf("Module: %s (%d variables found)\n", path, len(stats.variables)) - - for name, count := range stats.variables { - fmt.Printf("%s : %d\n", name, count) - } - fmt.Println("") + fmt.Printf("\n%d modules processed", len(modules)) return nil } -type VariablesUsage struct { - variables map[string]int -} - -func findVariablesUsage(path string) (VariablesUsage, error) { - out := VariablesUsage{variables: map[string]int{}} - - module, diagnostics := tfconfig.LoadModule(path) - if diagnostics.HasErrors() { - return out, diagnostics.Err() - } - - result, err := countVariables(path, module) - if err != nil { - return out, err - } - - out.variables = result - - return out, nil -} - -func countVariables(path string, tfconfig *tfconfig.Module) (map[string]int, error) { - out := map[string]int{} - - files, err := os.ReadDir(path) - if err != nil { - return out, err - } - - for _, file := range files { - if filepath.Ext(file.Name()) == ".tf" { - data, err := os.ReadFile(filepath.Join(path, file.Name())) - if err != nil { - return out, err - } - - content := string(data) - - for variable := range tfconfig.Variables { - regex := regexp.MustCompile(fmt.Sprintf(`var\.%s\W`, variable)) - matches := regex.FindAllStringIndex(content, -1) - - out[variable] += len(matches) - } - - } - } - - return out, err -} - -func findTfModules(path string) (map[string]bool, error) { - var directories = make(map[string]bool) - - err := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - if filepath.Ext(path) == ".tf" { - module := filepath.Dir(path) - log.Debugf("Visited: %s\n", module) - if _, ok := directories[module]; !ok { - directories[module] = true - } - } - return nil - }) - - if err != nil { - return nil, err - } - - return directories, nil -} - func Execute() error { return rootCmd.Execute() } func init() { - rootCmd.Flags().Bool("unused-only", false, "Display only unused variables") + rootCmd.Flags().Bool("unused-only", false, "Display only unused values") + rootCmd.Flags().Bool("variables", false, "Display only unused variables") + rootCmd.Flags().Bool("locals", false, "Display only unused locals") } diff --git a/cmd/root_test.go b/cmd/root_test.go deleted file mode 100644 index ab1a997..0000000 --- a/cmd/root_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package cmd - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -func TestFindTfModules(t *testing.T) { - t.Run("must found 3 modules", func(t *testing.T) { - path := "./testdata" - - result, err := findTfModules(path) - assert.Equal(t, err, nil) - assert.Equal(t, len(result), 3) - - assert.Contains(t, result, "testdata") - assert.Contains(t, result, "testdata/tf") - assert.Contains(t, result, "testdata/tf/1") - }) -} - -func TestFindVariablesUsage(t *testing.T) { - t.Run("should find all variables", func(t *testing.T) { - path := "./testdata/tf" - - out, err := findVariablesUsage(path) - assert.Equal(t, err, nil) - assert.Equal(t, 1, out.variables["name"]) - assert.Equal(t, 1, out.variables["region"]) - assert.Equal(t, 1, out.variables["instance_ids"]) - assert.Equal(t, 0, out.variables["legacy"]) - }) -} diff --git a/go.mod b/go.mod index 3c923c6..9ea7ae2 100644 --- a/go.mod +++ b/go.mod @@ -3,24 +3,22 @@ module github.com/sylwit/terraform-cleaner go 1.19 require ( - github.com/hashicorp/terraform-config-inspect v0.0.0-20230223165911-2d94e3d51111 + github.com/hashicorp/hcl/v2 v2.16.2 github.com/sirupsen/logrus v1.9.0 github.com/spf13/cobra v1.6.1 ) require ( github.com/agext/levenshtein v1.2.2 // indirect - github.com/apparentlymart/go-textseg v1.0.0 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-cmp v0.3.0 // indirect - github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f // indirect - github.com/hashicorp/hcl/v2 v2.0.0 // indirect + github.com/google/go-cmp v0.3.1 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/mitchellh/go-wordwrap v1.0.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect; indirect˚ + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/testify v1.8.2 - github.com/zclconf/go-cty v1.1.0 // indirect + github.com/stretchr/testify v1.8.2 // indirect + github.com/zclconf/go-cty v1.12.1 // indirect golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect golang.org/x/text v0.3.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 6938fd8..c5c2a43 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= github.com/apparentlymart/go-textseg v1.0.0 h1:rRmlIsPEEhUTIKQb7T++Nz/A5Q6C9IuX2wFoYVvnCs0= github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -14,10 +16,14 @@ github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f h1:UdxlrJz4JOnY8W+DbLISwf2B8WXEolNRA8BGCwI9jws= github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= github.com/hashicorp/hcl/v2 v2.0.0 h1:efQznTz+ydmQXq3BOnRa3AXzvCeTq1P4dKj/z5GLlY8= github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= +github.com/hashicorp/hcl/v2 v2.16.2 h1:mpkHZh/Tv+xet3sy3F9Ld4FyI2tUpWe9x3XtPx9f1a0= +github.com/hashicorp/hcl/v2 v2.16.2/go.mod h1:JRmR89jycNkrrqnMmvPDMd56n1rQJ2Q6KocSLCMCXng= github.com/hashicorp/terraform-config-inspect v0.0.0-20230223165911-2d94e3d51111 h1:Q5X4tdq+BK6DbQWqu16uUfBsRcJKuK0h4h7q3PojiWM= github.com/hashicorp/terraform-config-inspect v0.0.0-20230223165911-2d94e3d51111/go.mod h1:l8HcFPm9cQh6Q0KSWoYPiePqMvRFenybP1CH2MjKdlg= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= @@ -56,6 +62,8 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/zclconf/go-cty v1.1.0 h1:uJwc9HiBOCpoKIObTQaLR+tsEXx1HBHnOsOOpcdhZgw= github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= +github.com/zclconf/go-cty v1.12.1 h1:PcupnljUm9EIvbgSHQnHhUr3fO6oFmkOrvs2BAFNXXY= +github.com/zclconf/go-cty v1.12.1/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/terraform/main.go b/terraform/main.go new file mode 100644 index 0000000..1381dd0 --- /dev/null +++ b/terraform/main.go @@ -0,0 +1,191 @@ +package terraform + +import ( + "errors" + "fmt" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" + log "github.com/sirupsen/logrus" + "io/fs" + "os" + "path/filepath" + "regexp" +) + +type ModuleUsage struct { + Path string + Variables map[string]int + Locals map[string]int + file *hclwrite.File +} + +func NewModuleUsage(path string) (*ModuleUsage, error) { + m := &ModuleUsage{ + Path: path, + Variables: map[string]int{}, + Locals: map[string]int{}, + } + + src, err := LoadTfModule(path) + if err != nil { + return nil, err + } + + f, diags := hclwrite.ParseConfig(src, "", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + return nil, errors.New(path + ":" + diags.Error()) + } + + m.file = f + err = m.processUsage() + + return m, err +} + +func ListTfModules(path string) (map[string]bool, error) { + var directories = make(map[string]bool) + + err := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if filepath.Ext(path) == ".tf" { + module := filepath.Dir(path) + log.Debugf("Visited: %s\n", module) + if _, ok := directories[module]; !ok { + directories[module] = true + } + } + return nil + }) + + if err != nil { + return nil, err + } + + return directories, nil +} + +func LoadTfModule(path string) ([]byte, error) { + var out []byte + + files, err := os.ReadDir(path) + if err != nil { + return out, err + } + for _, file := range files { + if filepath.Ext(file.Name()) == ".tf" { + data, err := os.ReadFile(filepath.Join(path, file.Name())) + if err != nil { + return out, err + } + out = append(out, '\n') + out = append(out, data...) + } + } + return out, nil +} + +func (m ModuleUsage) processUsage() error { + body := m.file.Body() + bodyStr := string(m.file.Bytes()) + for _, block := range body.Blocks() { + blockType := block.Type() + if blockType == "variable" { + name := block.Labels()[0] + m.Variables[name] = countPattern(bodyStr, fmt.Sprintf(`var\.%s\W`, name)) + } else if blockType == "locals" { + attribs := block.Body().Attributes() + for attrib := range attribs { + m.Locals[attrib] = countPattern(bodyStr, fmt.Sprintf(`local\.%s\W`, attrib)) + } + } + + } + + return nil +} + +func countPattern(content string, pattern string) int { + regex := regexp.MustCompile(pattern) + matches := regex.FindAllStringIndex(content, -1) + + return len(matches) +} + +func (m ModuleUsage) DisplayLocals(unusedOnly bool) error { + return m.Display(Locals, unusedOnly) +} + +func (m ModuleUsage) DisplayVariables(unusedOnly bool) error { + return m.Display(Variables, unusedOnly) +} + +type DisplayType string + +const ( + All DisplayType = "all" + Variables DisplayType = "variables" + Locals DisplayType = "locals" +) + +func filterUnusedOnly(items map[string]int) map[string]int { + for name, count := range items { + if count > 0 { + delete(items, name) + } + } + return items +} + +func (m ModuleUsage) Display(dType DisplayType, unusedOnly bool) error { + variables := map[string]int{} + locals := map[string]int{} + + switch dType { + case Locals: + locals = m.Locals + if unusedOnly { + locals = filterUnusedOnly(locals) + } + case Variables: + variables = m.Variables + if unusedOnly { + variables = filterUnusedOnly(variables) + } + case All: + locals = m.Locals + variables = m.Variables + if unusedOnly { + locals = filterUnusedOnly(locals) + variables = filterUnusedOnly(variables) + } + default: + return errors.New(fmt.Sprintf("%s is an invalid display Type", dType)) + } + + if !unusedOnly || (unusedOnly && len(locals)+len(variables) > 0) { + fmt.Printf("\n \U0001F680 Module: %s\n", m.Path) + } + + if dType == All || dType == Variables { + if !unusedOnly || (unusedOnly && len(variables) > 0) { + fmt.Printf(" \U0001F449 %d variables found\n", len(variables)) + } + for name, count := range variables { + fmt.Printf("%s : %d\n", name, count) + } + } + + if dType == All || dType == Locals { + if !unusedOnly || (unusedOnly && len(locals) > 0) { + fmt.Printf("\U0001F449 %d locals found\n", len(locals)) + } + for name, count := range locals { + fmt.Printf("%s : %d\n", name, count) + } + + } + return nil +} diff --git a/terraform/main_test.go b/terraform/main_test.go new file mode 100644 index 0000000..c654007 --- /dev/null +++ b/terraform/main_test.go @@ -0,0 +1,40 @@ +package terraform + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestListTfModules(t *testing.T) { + t.Run("must found 3 modules", func(t *testing.T) { + path := "./testdata" + + result, err := ListTfModules(path) + assert.Equal(t, err, nil) + assert.Equal(t, len(result), 3) + + assert.Contains(t, result, "testdata") + assert.Contains(t, result, "testdata/tf") + assert.Contains(t, result, "testdata/tf/1") + }) +} + +func TestNewModuleUsage(t *testing.T) { + t.Run("should init and process ModuleUsage", func(t *testing.T) { + path := "./testdata/tf" + + moduleUsage, err := NewModuleUsage(path) + assert.Equal(t, err, nil) + assert.Equal(t, 4, len(moduleUsage.Variables)) + assert.Equal(t, 3, len(moduleUsage.Locals)) + + assert.Equal(t, 1, moduleUsage.Variables["name"]) + assert.Equal(t, 1, moduleUsage.Variables["region"]) + assert.Equal(t, 1, moduleUsage.Variables["instance_ids"]) + assert.Equal(t, 0, moduleUsage.Variables["legacy"]) + + assert.Equal(t, 1, moduleUsage.Locals["tags"]) + assert.Equal(t, 0, moduleUsage.Locals["dummy"]) + assert.Equal(t, 0, moduleUsage.Locals["dummy2"]) + }) +} diff --git a/cmd/testdata/main.tf b/terraform/testdata/main.tf similarity index 100% rename from cmd/testdata/main.tf rename to terraform/testdata/main.tf diff --git a/cmd/testdata/tf/1/main.tf b/terraform/testdata/tf/1/main.tf similarity index 100% rename from cmd/testdata/tf/1/main.tf rename to terraform/testdata/tf/1/main.tf diff --git a/cmd/testdata/tf/main.tf b/terraform/testdata/tf/main.tf similarity index 73% rename from cmd/testdata/tf/main.tf rename to terraform/testdata/tf/main.tf index 10d69e4..cb8e238 100644 --- a/cmd/testdata/tf/main.tf +++ b/terraform/testdata/tf/main.tf @@ -15,9 +15,18 @@ data "null_data_source" "values" { inputs = var.name } +locals { + dummy = "this is not used" + tags = { + service : "cleaner" + } + dummy2 = "not used either" +} + resource "null_resource" "cluster" { # Changes to any instance of the cluster requires re-provisioning triggers = { instance_ids = var.instance_ids + tags = local.tags } } diff --git a/cmd/testdata/tf/outputs.tf b/terraform/testdata/tf/outputs.tf similarity index 100% rename from cmd/testdata/tf/outputs.tf rename to terraform/testdata/tf/outputs.tf diff --git a/cmd/testdata/tf/variables.tf b/terraform/testdata/tf/variables.tf similarity index 100% rename from cmd/testdata/tf/variables.tf rename to terraform/testdata/tf/variables.tf