diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a738850 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +on: [push, pull_request] +name: Test +jobs: + test: + strategy: + matrix: + go-version: [1.17.x] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v2 + - name: Test + run: go test ./... diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9cc588c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing to tfrefactor +We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features +- Becoming a maintainer + +## We Develop with Github +We use github to host code, to track issues and feature requests, as well as accept pull requests. + +## Pull Requests +Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests: + +1. Fork the repo and create your branch from `main`. +2. If you've added code that should be tested, add tests. +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. Issue that pull request! + +## Any contributions you make will be under the MIT Software License +In short, when you submit code changes, your submissions are understood to be under the same [MPL 2.0 License](https://www.mozilla.org/en-US/MPL/2.0) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](https://github.com/craftvscruft/tfrefactor/issues) +We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/craftvscruft/tfrefactor/issues); it's that easy! + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +* Format with `go fmt` +* Lint with `go vet` + +## License +By contributing, you agree that your contributions will be licensed under its MPL2 License. + +## References +This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) diff --git a/README.md b/README.md index 88026aa..ab28a8b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,68 @@ -# TF Refactor -Automated refactoring for Terraform. +# Welcome to tfrefactor 👋 +![Version](https://img.shields.io/badge/version-0.0.1-blue.svg?cacheSeconds=2592000) +[![Documentation](https://img.shields.io/badge/documentation-yes-brightgreen.svg)](https://refactor.tf) +[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/kefranabg/readme-md-generator/graphs/commit-activity) +[![License: MPL2](https://img.shields.io/github/license/raymyers/tfrefactor)](https://github.com/craftvscruft/tfrefactor/blob/main/LICENSE) +[![Twitter: lambdapocalypse](https://img.shields.io/twitter/follow/lambdapocalypse.svg?style=social)](https://twitter.com/lambdapocalypse) -Coming soon (hopefully)! +> Automated refactoring for Terraform. -## Credits -Inspiration and test helper code from [hcledit](https://github.com/minamijoyo/hcledit) by -Masayuki Morita. +### 🏠 [Homepage](https://github.com/craftvscruft/tfrefactor) + +## Install + +Substitute ~/.local/bin for a prefered dir the is in your $PATH. + +```sh +git clone git@github.com:craftvscruft/tfrefactor.git +cd tfrefactor +go build + +cp bin/tfrefactor ~/.local/bin +``` + +## Usage + +```sh +tfrefactor +``` + +## Run tests + +```sh +make test +``` + +## Author + +👤 **Ray Myers** + +* Website: https://www.youtube.com/channel/UC4nEbAo5xFsOZDk2v0RIGHA +* Twitter: [@lambdapocalypse](https://twitter.com/lambdapocalypse) +* GitHub: [@raymyers](https://github.com/raymyers) +* LinkedIn: [@cadrlife](https://linkedin.com/in/cadrlife) + +## 🤝 Contributing + +Contributions, issues and feature requests are welcome! + +Feel free to check [issues page](https://github.com/craftvscruft/tfrefactor/issues). You can also take a look at the [contributing guide](https://github.com/craftvscruft/tfrefactor/blob/main/CONTRIBUTING.md). + +## Show your support + +Give a ⭐️ if this project helped you! + +[![support us](https://img.shields.io/badge/become-a%20patreon%20us-orange.svg?cacheSeconds=2592000)](https://www.patreon.com/craftvscruft) + +## Acknowledgement + +Inspiration and test helper code from [hcledit](https://github.com/minamijoyo/hcledit) by Masayuki Morita. + +## 📝 License + +Copyright © 2022 [Ray Myers](https://github.com/raymyers). + +This project is [MPL2](https://github.com/craftvscruft/tfrefactor/blob/main/LICENSE) licensed. + +*** +_This README was generated with ❤️ by [readme-md-generator](https://github.com/kefranabg/readme-md-generator)_ diff --git a/cmd/rename.go b/cmd/rename.go index c5c2bcf..cf55cc5 100644 --- a/cmd/rename.go +++ b/cmd/rename.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "io/ioutil" "log" "os" @@ -28,6 +29,7 @@ Arguments: } flags := cmd.Flags() flags.StringP("config", "c", "-", "Path of terraform to modify, defaults to current.") + flags.BoolP("force", "f", false, "Skip interactive approval of update before applying") return cmd } @@ -48,6 +50,7 @@ func runRenameCmd(cmd *cobra.Command, args []string) error { toAddress := args[1] configPath, err := cmd.Flags().GetString("config") CheckFatal(err) + if configPath == "-" { configPath, err = os.Getwd() CheckFatal(err) @@ -55,5 +58,55 @@ func runRenameCmd(cmd *cobra.Command, args []string) error { CheckFatal(err) _, err = fmt.Fprintf(cmd.OutOrStdout(), "Renaming '%v' -> '%v' in %v\n", fromAddress, toAddress, configPath) - return refactor.Rename(fromAddress, toAddress, configPath) + if err != nil { + return err + } + plan, err := refactor.Rename(fromAddress, toAddress, configPath) + if err != nil { + return err + } + err = approveAndApplyUpdate(cmd, plan) + return err +} + +func approveAndApplyUpdate(cmd *cobra.Command, plan *refactor.UpdatePlan) error { + autoApprove, err := cmd.Flags().GetBool("force") + CheckFatal(err) + if len(plan.FileUpdates) > 0 { + if autoApprove { + err = applyUpdate(plan) + CheckFatal(err) + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Done.") + CheckFatal(err) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Update %v file(s)? [y/N]: ", len(plan.FileUpdates)) + CheckFatal(err) + var in string + _, _ = fmt.Fscanln(cmd.InOrStdin(), &in) + // Ignore Fscanln err because empty input is OK. + if in == "Y" || in == "y" { + err = applyUpdate(plan) + CheckFatal(err) + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Done.") + CheckFatal(err) + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "\nAborted.\n") + CheckFatal(err) + } + } + } else { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "\nNo updates required.\n") + CheckFatal(err) + } + return nil +} + +func applyUpdate(plan *refactor.UpdatePlan) error { + for _, update := range plan.FileUpdates { + err := ioutil.WriteFile(update.Filename, []byte(update.AfterText), 0644) + if err != nil { + return err + } + } + return nil } diff --git a/go.mod b/go.mod index e0d14bf..7941969 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/craftvscruft/tfrefactor go 1.17 require ( - github.com/hashicorp/hcl/v2 v2.11.1 github.com/pmezard/go-difflib v1.0.0 + github.com/raymyers/hcl/v2 v2.11.1-set-attr-name-fork github.com/spf13/cobra v1.3.0 ) diff --git a/go.sum b/go.sum index 41d0310..103807c 100644 --- a/go.sum +++ b/go.sum @@ -223,10 +223,7 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/hcl/v2 v2.11.1 h1:yTyWcXcm9XB0TEkyU/JCRU6rYy4K+mgLtzn2wlrJbcc= -github.com/hashicorp/hcl/v2 v2.11.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= @@ -312,6 +309,8 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/raymyers/hcl/v2 v2.11.1-set-attr-name-fork h1:k4kbA/GigFGpzqNy9DjQiXIco/Amth1UBzJV9USBql0= +github.com/raymyers/hcl/v2 v2.11.1-set-attr-name-fork/go.mod h1:XDvt8jUh7HjUiJVkGKpmg+SfETbW/rdMb8HPZNlsqck= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/refactor/address.go b/refactor/address.go index bca9285..8a6d117 100644 --- a/refactor/address.go +++ b/refactor/address.go @@ -2,24 +2,24 @@ package refactor import "strings" -type ElementType string +type TypeName string const ( - Resource ElementType = "resource" - Output = "output" - Var = "var" - Local = "local" - Data = "data" - Module = "module" + TypeResource TypeName = "resource" + TypeOutput = "output" + TypeVar = "var" + TypeLocal = "local" + TypeData = "data" + TypeModule = "module" ) type Address struct { - elementType ElementType + elementType TypeName labels []string } func (a *Address) RefNameArray() []string { - if a.elementType == Resource { + if a.elementType == TypeResource { return a.labels } return append([]string{string(a.elementType)}, a.labels...) @@ -30,7 +30,7 @@ func (a *Address) RefName() string { } func (a *Address) BlockType() string { - if a.elementType == Var { + if a.elementType == TypeVar { return "variable" } return string(a.elementType) @@ -39,39 +39,39 @@ func (a *Address) BlockType() string { func ParseAddress(addr string) *Address { parts := strings.Split(addr, ".") switch parts[0] { - case string(Resource), "resources": + case string(TypeResource), "resources": return &Address{ - elementType: Resource, + elementType: TypeResource, labels: parts[1:], } - case string(Output), "outputs": + case string(TypeOutput), "outputs": return &Address{ - elementType: Output, + elementType: TypeOutput, labels: parts[1:], } - case string(Var), "vars", "variable", "variables": + case string(TypeVar), "vars", "variable", "variables": return &Address{ - elementType: Var, + elementType: TypeVar, labels: parts[1:], } - case string(Local), "locals": + case string(TypeLocal), "locals": return &Address{ - elementType: Local, + elementType: TypeLocal, labels: parts[1:], } - case string(Data): + case string(TypeData): return &Address{ - elementType: Data, + elementType: TypeData, labels: parts[1:], } - case string(Module): + case string(TypeModule): return &Address{ - elementType: Module, + elementType: TypeModule, labels: parts[1:], } default: return &Address{ - elementType: Resource, + elementType: TypeResource, labels: parts, } } diff --git a/refactor/parse.go b/refactor/parse.go index f41e1b0..817e499 100644 --- a/refactor/parse.go +++ b/refactor/parse.go @@ -8,8 +8,8 @@ import ( "os" "runtime/debug" - "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/raymyers/hcl/v2" + "github.com/raymyers/hcl/v2/hclwrite" ) func ParseHclFile(filename string) (*hclwrite.File, error) { diff --git a/refactor/rename.go b/refactor/rename.go index e83f1a9..3df7940 100644 --- a/refactor/rename.go +++ b/refactor/rename.go @@ -4,31 +4,33 @@ import ( "fmt" "path/filepath" - "github.com/hashicorp/hcl/v2/hclwrite" "github.com/pmezard/go-difflib/difflib" + "github.com/raymyers/hcl/v2" + "github.com/raymyers/hcl/v2/hclwrite" ) -func Rename(fromAddressString, toAddressString, configPath string) error { +func Rename(fromAddressString, toAddressString, configPath string) (*UpdatePlan, error) { configPattern := filepath.Join(configPath, "*.tf") _, _ = fmt.Println(configPattern) filenames, err := filepath.Glob(configPattern) if err != nil { - return err + return nil, err } fromAddress := ParseAddress(fromAddressString) toAddress := ParseAddress(toAddressString) if len(fromAddress.RefNameArray()) != len(toAddress.RefNameArray()) { - return fmt.Errorf("Addresses are different lengths: '%v' and '%v'", fromAddress.RefName(), toAddress.RefName()) + return nil, fmt.Errorf("Addresses are different lengths: '%v' and '%v'", fromAddress.RefName(), toAddress.RefName()) } + plan := newUpdatePlan() for _, filename := range filenames { parsedFile, err := ParseHclFile(filename) beforeText := string(parsedFile.Bytes()) if err != nil { - return err + return nil, err } err = RenameInFile(filename, parsedFile, fromAddress, toAddress) if err != nil { - return err + return nil, err } afterText := string(parsedFile.Bytes()) diff := difflib.UnifiedDiff{ @@ -41,23 +43,66 @@ func Rename(fromAddressString, toAddressString, configPath string) error { diffText, _ := difflib.GetUnifiedDiffString(diff) if len(diffText) > 0 { fmt.Printf("Diff for %v\n%v\n", filename, diffText) + plan.addFileUpdate(&FileUpdate{filename, beforeText, afterText}) } } - return nil + return &plan, nil +} + +func createTraversal(labels []string) (traversal hcl.Traversal) { + traversal = hcl.Traversal{ + hcl.TraverseRoot{ + Name: labels[0], + }, + } + for _, label := range labels[1:] { + traversal = append(traversal, hcl.TraverseAttr{ + Name: label, + }) + } + return } func RenameInFile(filename string, file *hclwrite.File, fromAddress, toAddress *Address) error { - matchingBlocks := findBlocks(file.Body(), fromAddress) - for _, block := range matchingBlocks { - _, _ = fmt.Printf("Renaming %v %v in %v\n", block.Type(), block.Labels(), filename) - block.SetType(string(toAddress.BlockType())) - block.SetLabels(toAddress.labels) + if fromAddress.elementType == TypeLocal { + if err := RenameLocalInFile(filename, file, fromAddress, toAddress); err != nil { + return err + } + } else { + matchingBlocks := findBlocks(file.Body(), fromAddress) + for _, block := range matchingBlocks { + _, _ = fmt.Printf("Renaming %v %v in %v\n", block.Type(), block.Labels(), filename) + block.SetType(string(toAddress.BlockType())) + block.SetLabels(toAddress.labels) + if fromAddress.elementType == TypeResource && toAddress.elementType == TypeResource { + file.Body().AppendNewline() + movedBlock := file.Body().AppendNewBlock("moved", []string{}) + + movedBlock.Body().SetAttributeTraversal("from", createTraversal(fromAddress.labels)) + movedBlock.Body().SetAttributeTraversal("to", createTraversal(toAddress.labels)) + } + } } + RenameVariablePrefixInBody("", file.Body(), fromAddress, toAddress) return nil } +func RenameLocalInFile(filename string, file *hclwrite.File, fromAddress, toAddress *Address) error { + fromName := fromAddress.labels[0] + toName := toAddress.labels[0] + for _, block := range file.Body().Blocks() { + if "locals" == block.Type() { + attr := block.Body().GetAttribute(fromName) + if attr != nil { + attr.SetName(toName) + } + } + } + return nil +} + func RenameVariablePrefixInBody(blockType string, body *hclwrite.Body, fromAddress, toAddress *Address) { for name, attr := range body.Attributes() { if !(blockType == "moved" && name == "from") { diff --git a/refactor/rename_test.go b/refactor/rename_test.go index 1b77fa6..4630c48 100644 --- a/refactor/rename_test.go +++ b/refactor/rename_test.go @@ -55,6 +55,11 @@ resource "a" "c" { b2 "l2" { } + +moved { + from = a.b + to = a.c +} `, }, { @@ -81,6 +86,11 @@ resource "aws_iam_policy" "read_only_restrictions2" { b2 "l2" { } + +moved { + from = aws_iam_policy.read_only_restrictions + to = aws_iam_policy.read_only_restrictions2 +} `, }, { @@ -127,6 +137,50 @@ variable "b" { b2 "l2" { a0 = var.b } +`, + }, + { + name: "local_in_interpolation", + src: ` +locals { + a = 1 +} + +b2 "l2" { + a0 = "pre${local.a}" +} +`, + from: "local.a", + to: "local.b", + ok: true, + want: ` +locals { + b = 1 +} + +b2 "l2" { + a0 = "pre${local.b}" +} +`, + }, + { + name: "local_with_comments", + src: ` +locals { + // before + a = 1 // line + // after +} +`, + from: "local.a", + to: "local.b", + ok: true, + want: ` +locals { + // before + b = 1 // line + // after +} `, }, { @@ -173,6 +227,11 @@ moved { from = aws_instance.z to = aws_instance.b } + +moved { + from = aws_instance.a + to = aws_instance.b +} `, }, { diff --git a/refactor/update_plan.go b/refactor/update_plan.go new file mode 100644 index 0000000..dd38d3b --- /dev/null +++ b/refactor/update_plan.go @@ -0,0 +1,18 @@ +package refactor + +type FileUpdate struct { + Filename string + BeforeText string + AfterText string +} + +type UpdatePlan struct { + FileUpdates []*FileUpdate +} + +func (p *UpdatePlan) addFileUpdate(fileUpdate *FileUpdate) { + p.FileUpdates = append(p.FileUpdates, fileUpdate) +} +func newUpdatePlan() UpdatePlan { + return UpdatePlan{FileUpdates: []*FileUpdate{}} +}