diff --git a/.github/workflows/code-health.yml b/.github/workflows/code-health.yml index 86c7e00..b1c1fed 100644 --- a/.github/workflows/code-health.yml +++ b/.github/workflows/code-health.yml @@ -18,6 +18,8 @@ jobs: go-version-file: 'go.mod' - name: Build run: make build + - name: Unit Test + run: make test lint: runs-on: ubuntu-latest permissions: {} diff --git a/.gitignore b/.gitignore index a9d0947..497fe3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin bin-plugin +.vscode diff --git a/Makefile b/Makefile index a21a543..203e514 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,14 @@ tools: ## Install the dev tools (dependencies) clean: ## Clean binary folders rm -rf ./bin ./bin-plugin +.PHONY: test +test: ## Run unit tests + go test ./... -timeout=30s -parallel=4 -race + +.PHONY: test-update +test-update: ## Run unit tests and update the golden files + go test ./... -timeout=30s -parallel=4 -race -update + .PHONY: local local: clean build ## Allow to run the plugin locally @echo "==> Configuring plugin locally" diff --git a/README.md b/README.md index 84f05ed..87ecd0a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,19 @@ atlas plugin install github.com/mongodb-labs/atlas-cli-plugin-terraform ## Usage +### Convert cluster to advanced_cluster v2 +If you want to convert a Terraform configuration from `mongodbatlas_cluster` to `mongodbatlas_advanced_cluster` schema v2, use the following command: +```bash +atlas terraform clusterToAdvancedCluster --file in.tf --output out.tf +``` + +you can also use shorter aliases, e.g.: +```bash +atlas tf clu2adv -f in.tf -o out.tf +``` + +If you want to overwrite the output file if it exists, or even use the same output file as the input file, use the `--overwriteOutput true` or the `-w` flag. + ## Contributing diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 048d0a7..a4510dc 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -4,7 +4,8 @@ import ( "fmt" "os" - "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/cli/hello" + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/cli/adv2" + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/cli/clu2adv" "github.com/spf13/cobra" ) @@ -14,10 +15,7 @@ func main() { Short: "Utilities for Terraform's MongoDB Atlas Provider", Aliases: []string{"tf"}, } - - terraformCmd.AddCommand( - hello.Builder(), - ) + terraformCmd.AddCommand(clu2adv.Builder(), adv2.Builder()) completionOption := &cobra.CompletionOptions{ DisableDefaultCmd: true, diff --git a/go.mod b/go.mod index 2010e5f..60a3b36 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,28 @@ module github.com/mongodb-labs/atlas-cli-plugin-terraform go 1.23.4 -require github.com/spf13/cobra v1.8.1 +require ( + github.com/hashicorp/hcl/v2 v2.23.0 + github.com/sebdah/goldie/v2 v2.5.5 + github.com/spf13/afero v1.12.0 + github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.4.0 +) require ( + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/zclconf/go-cty v1.16.2 // indirect + golang.org/x/mod v0.22.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 912390a..48a9880 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,61 @@ +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +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/cpuguy83/go-md2man/v2 v2.0.4/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= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= +github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY= +github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= +github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/adv2/adv2.go b/internal/cli/adv2/adv2.go new file mode 100644 index 0000000..a4bd704 --- /dev/null +++ b/internal/cli/adv2/adv2.go @@ -0,0 +1,20 @@ +package adv2 + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func Builder() *cobra.Command { + cmd := &cobra.Command{ + Use: "advancedClusterV1ToV2", + Short: "Convert advanced_cluster v1 to v2", + Long: "Convert a Terraform configuration from mongodbatlas_advanced_cluster schema v1 to v2", + Aliases: []string{"adv2"}, + RunE: func(_ *cobra.Command, _ []string) error { + return errors.New("TODO: not implemented yet, will be implemented in the future") + }, + } + return cmd +} diff --git a/internal/cli/clu2adv/clu2adv.go b/internal/cli/clu2adv/clu2adv.go new file mode 100644 index 0000000..a5b4080 --- /dev/null +++ b/internal/cli/clu2adv/clu2adv.go @@ -0,0 +1,28 @@ +package clu2adv + +import ( + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +func Builder() *cobra.Command { + o := &opts{fs: afero.NewOsFs()} + cmd := &cobra.Command{ + Use: "clusterToAdvancedCluster", + Short: "Convert cluster to advanced_cluster v2", + Long: "Convert a Terraform configuration from mongodbatlas_cluster to mongodbatlas_advanced_cluster schema v2", + Aliases: []string{"clu2adv"}, + RunE: func(_ *cobra.Command, _ []string) error { + if err := o.PreRun(); err != nil { + return err + } + return o.Run() + }, + } + cmd.Flags().StringVarP(&o.file, "file", "f", "", "input file") + _ = cmd.MarkFlagRequired("file") + cmd.Flags().StringVarP(&o.output, "output", "o", "", "output file") + _ = cmd.MarkFlagRequired("output") + cmd.Flags().BoolVarP(&o.overwriteOutput, "overwriteOutput", "w", false, "overwrite output file if exists") + return cmd +} diff --git a/internal/cli/clu2adv/opts.go b/internal/cli/clu2adv/opts.go new file mode 100644 index 0000000..eaf0756 --- /dev/null +++ b/internal/cli/clu2adv/opts.go @@ -0,0 +1,41 @@ +package clu2adv + +import ( + "fmt" + + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/file" + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/hcl" + "github.com/spf13/afero" +) + +type opts struct { + fs afero.Fs + file string + output string + overwriteOutput bool +} + +func (o *opts) PreRun() error { + if err := file.MustExist(o.fs, o.file); err != nil { + return err + } + if !o.overwriteOutput { + return file.MustNotExist(o.fs, o.output) + } + return nil +} + +func (o *opts) Run() error { + inConfig, err := afero.ReadFile(o.fs, o.file) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", o.file, err) + } + outConfig, err := hcl.ClusterToAdvancedCluster(inConfig) + if err != nil { + return err + } + if err := afero.WriteFile(o.fs, o.output, outConfig, 0o600); err != nil { + return fmt.Errorf("failed to write file %s: %w", o.output, err) + } + return nil +} diff --git a/internal/cli/hello/hello.go b/internal/cli/hello/hello.go deleted file mode 100644 index 7019f51..0000000 --- a/internal/cli/hello/hello.go +++ /dev/null @@ -1,17 +0,0 @@ -package hello - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -func Builder() *cobra.Command { - return &cobra.Command{ - Use: "hello", - Short: "The Hello World command", - Run: func(_ *cobra.Command, _ []string) { - fmt.Println("Hello World, Terraform! This command will be eventually deleted.") - }, - } -} diff --git a/internal/file/file.go b/internal/file/file.go new file mode 100644 index 0000000..0dfdd5a --- /dev/null +++ b/internal/file/file.go @@ -0,0 +1,41 @@ +package file + +import ( + "fmt" + + "github.com/spf13/afero" +) + +func Exists(fs afero.Fs, filename string) (exists bool, err error) { + exists, err = afero.Exists(fs, filename) + if err != nil { + return false, newError(err, filename) + } + return +} + +func MustExist(fs afero.Fs, filename string) error { + exists, err := Exists(fs, filename) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("file must exist: %s", filename) + } + return nil +} + +func MustNotExist(fs afero.Fs, filename string) error { + exists, err := Exists(fs, filename) + if err != nil { + return err + } + if exists { + return fmt.Errorf("file must not exist: %s", filename) + } + return nil +} + +func newError(err error, filename string) error { + return fmt.Errorf("error in file %s: %w", filename, err) +} diff --git a/internal/hcl/hcl.go b/internal/hcl/hcl.go new file mode 100644 index 0000000..de6ce1f --- /dev/null +++ b/internal/hcl/hcl.go @@ -0,0 +1,60 @@ +package hcl + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" +) + +const ( + resourceType = "resource" + cluster = "mongodbatlas_cluster" + advCluster = "mongodbatlas_advanced_cluster" +) + +// ClusterToAdvancedCluster transforms all mongodbatlas_cluster definitions in a +// Terraform configuration file into mongodbatlas_advanced_cluster schema v2 definitions. +// All other resources and data sources are left untouched. +// TODO: at the moment it just changes the resource type. +func ClusterToAdvancedCluster(config []byte) ([]byte, error) { + parser, err := getParser(config) + if err != nil { + return nil, err + } + for _, resource := range parser.Body().Blocks() { + labels := resource.Labels() + resourceName := labels[0] + if resource.Type() != resourceType || resourceName != cluster { + continue + } + resourceBody := resource.Body() + + // TODO: Do the full transformation + labels[0] = advCluster + resource.SetLabels(labels) + resourceBody.AppendNewline() + appendComment(resourceBody, "Generated by atlas-cli-plugin-terraform.") + appendComment(resourceBody, "Please confirm that all references to this resource are updated.") + } + return parser.Bytes(), nil +} + +func getParser(config []byte) (*hclwrite.File, error) { + parser, diags := hclwrite.ParseConfig(config, "", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + return nil, fmt.Errorf("failed to parse Terraform config file: %s", diags.Error()) + } + return parser, nil +} + +func appendComment(body *hclwrite.Body, comment string) { + tokens := hclwrite.Tokens{ + &hclwrite.Token{ + Type: hclsyntax.TokenComment, + Bytes: []byte("# " + comment + "\n"), + }, + } + body.AppendUnstructuredTokens(tokens) +} diff --git a/internal/hcl/hcl_test.go b/internal/hcl/hcl_test.go new file mode 100644 index 0000000..1d09ac2 --- /dev/null +++ b/internal/hcl/hcl_test.go @@ -0,0 +1,37 @@ +package hcl_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/hcl" + "github.com/sebdah/goldie/v2" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClusterToAdvancedCluster(t *testing.T) { + const ( + root = "testdata/clu2adv" + inSuffix = ".in.tf" + outSuffix = ".out.tf" + ) + g := goldie.New(t, + goldie.WithFixtureDir(root), + goldie.WithNameSuffix(outSuffix)) + fs := afero.NewOsFs() + pattern := filepath.Join(root, "*"+inSuffix) + inputFiles, err := afero.Glob(fs, pattern) + require.NoError(t, err) + assert.NotEmpty(t, inputFiles) + for _, inputFile := range inputFiles { + testName := strings.TrimSuffix(filepath.Base(inputFile), inSuffix) + inConfig, err := afero.ReadFile(fs, inputFile) + require.NoError(t, err) + outConfig, err := hcl.ClusterToAdvancedCluster(inConfig) + require.NoError(t, err) + g.Assert(t, testName, outConfig) + } +} diff --git a/internal/hcl/testdata/clu2adv/basic.in.tf b/internal/hcl/testdata/clu2adv/basic.in.tf new file mode 100644 index 0000000..d58a798 --- /dev/null +++ b/internal/hcl/testdata/clu2adv/basic.in.tf @@ -0,0 +1,15 @@ +resource "resource1" "res1" { + name = "name1" +} + +resource "mongodbatlas_cluster" "cluster1" { + name = "name2" +} + +data "resource2" "res2" { + name = "name3" +} + +data "mongodbatlas_cluster" "cluster2" { + name = "name4" +} diff --git a/internal/hcl/testdata/clu2adv/basic.out.tf b/internal/hcl/testdata/clu2adv/basic.out.tf new file mode 100644 index 0000000..5c68226 --- /dev/null +++ b/internal/hcl/testdata/clu2adv/basic.out.tf @@ -0,0 +1,18 @@ +resource "resource1" "res1" { + name = "name1" +} + +resource "mongodbatlas_advanced_cluster" "cluster1" { + name = "name2" + + # Generated by atlas-cli-plugin-terraform. + # Please confirm that all references to this resource are updated. +} + +data "resource2" "res2" { + name = "name3" +} + +data "mongodbatlas_cluster" "cluster2" { + name = "name4" +}