diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index f4b22c9..053a259 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/cli/adv2v2" "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/cli/clu2adv" "github.com/spf13/cobra" ) @@ -15,6 +16,7 @@ func main() { Aliases: []string{"tf"}, } terraformCmd.AddCommand(clu2adv.Builder()) + terraformCmd.AddCommand(adv2v2.Builder()) completionOption := &cobra.CompletionOptions{ DisableDefaultCmd: true, diff --git a/internal/cli/adv2v2/adv2v2.go b/internal/cli/adv2v2/adv2v2.go new file mode 100644 index 0000000..94b8cec --- /dev/null +++ b/internal/cli/adv2v2/adv2v2.go @@ -0,0 +1,25 @@ +package adv2v2 + +import ( + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/cli" + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/convert" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +func Builder() *cobra.Command { + o := &cli.BaseOpts{ + Fs: afero.NewOsFs(), + Convert: convert.AdvancedClusterToV2, + } + cmd := &cobra.Command{ + Use: "advancedClusterToV2", + Short: "Convert advanced_cluster from provider version 1 to 2", + Long: "Convert a Terraform configuration from mongodbatlas_advanced_cluster in provider version 1.X.X (SDKv2)" + + " to version 2.X.X (TPF - Terraform Plugin Framework)", + Aliases: []string{"adv2v2"}, + RunE: o.RunE, + } + cli.SetupCommonFlags(cmd, o) + return cmd +} diff --git a/internal/cli/clu2adv/clu2adv.go b/internal/cli/clu2adv/clu2adv.go index 68bbb9d..9907e86 100644 --- a/internal/cli/clu2adv/clu2adv.go +++ b/internal/cli/clu2adv/clu2adv.go @@ -1,34 +1,34 @@ package clu2adv import ( + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/cli" + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/convert" "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/flag" "github.com/spf13/afero" "github.com/spf13/cobra" ) func Builder() *cobra.Command { - o := &opts{fs: afero.NewOsFs()} + o := &struct { + *cli.BaseOpts + includeMoved bool + }{ + BaseOpts: &cli.BaseOpts{ + Fs: afero.NewOsFs(), + }, + } + o.Convert = func(config []byte) ([]byte, error) { + return convert.ClusterToAdvancedCluster(config, o.includeMoved) + } cmd := &cobra.Command{ Use: "clusterToAdvancedCluster", Short: "Convert cluster to advanced_cluster preview provider 2.0.0", Long: "Convert a Terraform configuration from mongodbatlas_cluster to " + "mongodbatlas_advanced_cluster preview provider 2.0.0", Aliases: []string{"clu2adv"}, - RunE: func(_ *cobra.Command, _ []string) error { - if err := o.PreRun(); err != nil { - return err - } - return o.Run() - }, + RunE: o.RunE, } - cmd.Flags().StringVarP(&o.file, flag.File, flag.FileShort, "", "input file") - _ = cmd.MarkFlagRequired(flag.File) - cmd.Flags().StringVarP(&o.output, flag.Output, flag.OutputShort, "", "output file") - _ = cmd.MarkFlagRequired(flag.Output) - cmd.Flags().BoolVarP(&o.replaceOutput, flag.ReplaceOutput, flag.ReplaceOutputShort, false, - "replace output file if exists") - cmd.Flags().BoolVarP(&o.watch, flag.Watch, flag.WatchShort, false, - "keeps the plugin running and watches the input file for changes") + cli.SetupCommonFlags(cmd, o.BaseOpts) cmd.Flags().BoolVarP(&o.includeMoved, flag.IncludeMoved, flag.IncludeMovedShort, false, "include moved blocks in the output file") return cmd diff --git a/internal/cli/clu2adv/opts.go b/internal/cli/clu2adv/opts.go deleted file mode 100644 index 214b055..0000000 --- a/internal/cli/clu2adv/opts.go +++ /dev/null @@ -1,97 +0,0 @@ -package clu2adv - -import ( - "errors" - "fmt" - - "github.com/fsnotify/fsnotify" - "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/convert" - "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/file" - "github.com/spf13/afero" -) - -type opts struct { - fs afero.Fs - file string - output string - replaceOutput bool - watch bool - includeMoved bool -} - -func (o *opts) PreRun() error { - if err := file.MustExist(o.fs, o.file); err != nil { - return err - } - if !o.replaceOutput { - return file.MustNotExist(o.fs, o.output) - } - return nil -} - -func (o *opts) Run() error { - if err := o.generateFile(false); err != nil { - return err - } - if o.watch { - return o.watchFile() - } - return nil -} - -func (o *opts) generateFile(allowParseErrors bool) 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 := convert.ClusterToAdvancedCluster(inConfig, o.includeMoved) - if err != nil { - if allowParseErrors { - outConfig = []byte("# CONVERT ERROR: " + err.Error() + "\n\n") - outConfig = append(outConfig, inConfig...) - } else { - 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 -} - -func (o *opts) watchFile() error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return nil - } - defer watcher.Close() - if err := watcher.Add(o.file); err != nil { - return err - } - for { - if err := o.waitForFileEvent(watcher); err != nil { - return err - } - } -} - -func (o *opts) waitForFileEvent(watcher *fsnotify.Watcher) error { - watcherError := errors.New("watcher has been closed") - select { - case event, ok := <-watcher.Events: - if !ok { - return watcherError - } - if event.Has(fsnotify.Write) { - if err := o.generateFile(true); err != nil { - return err - } - } - case err, ok := <-watcher.Errors: - if !ok { - return watcherError - } - return err - } - return nil -} diff --git a/internal/cli/common.go b/internal/cli/common.go new file mode 100644 index 0000000..efe06f5 --- /dev/null +++ b/internal/cli/common.go @@ -0,0 +1,130 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/fsnotify/fsnotify" + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/file" + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/flag" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +type ConvertFn func(config []byte) ([]byte, error) + +// BaseOpts contains common functionality for CLI commands that convert files. +type BaseOpts struct { + Fs afero.Fs + Convert ConvertFn + File string + Output string + ReplaceOutput bool + Watch bool +} + +// RunE is the entry point for the command. +func (o *BaseOpts) RunE(cmd *cobra.Command, args []string) error { + if err := o.preRun(); err != nil { + return err + } + return o.run() +} + +// preRun validates the input and output files before running the command. +func (o *BaseOpts) preRun() error { + if err := file.MustExist(o.Fs, o.File); err != nil { + return err + } + if !o.ReplaceOutput { + return file.MustNotExist(o.Fs, o.Output) + } + return nil +} + +// run executes the conversion and optionally watches for file changes. +func (o *BaseOpts) run() error { + if err := o.generateFile(false); err != nil { + return err + } + if o.Watch { + return o.watchFile() + } + return nil +} + +// generateFile reads the input file, converts it, and writes the output. +func (o *BaseOpts) generateFile(allowParseErrors bool) 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 := o.Convert(inConfig) + if err != nil { + if allowParseErrors { + outConfig = []byte("# CONVERT ERROR: " + err.Error() + "\n\n") + outConfig = append(outConfig, inConfig...) + } else { + 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 +} + +// watchFile watches the input file for changes and regenerates the output. +func (o *BaseOpts) watchFile() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + defer watcher.Close() + + if err := watcher.Add(o.File); err != nil { + return err + } + + for { + if err := o.waitForFileEvent(watcher); err != nil { + return err + } + } +} + +// waitForFileEvent waits for file system events and regenerates the output file. +func (o *BaseOpts) waitForFileEvent(watcher *fsnotify.Watcher) error { + watcherError := errors.New("watcher has been closed") + select { + case event, ok := <-watcher.Events: + if !ok { + return watcherError + } + if event.Has(fsnotify.Write) { + if err := o.generateFile(true); err != nil { + return err + } + } + case err, ok := <-watcher.Errors: + if !ok { + return watcherError + } + return err + } + return nil +} + +// SetupCommonFlags sets up the common flags used by all commands. +func SetupCommonFlags(cmd *cobra.Command, opts *BaseOpts) { + cmd.Flags().StringVarP(&opts.File, flag.File, flag.FileShort, "", "input file") + _ = cmd.MarkFlagRequired(flag.File) + cmd.Flags().StringVarP(&opts.Output, flag.Output, flag.OutputShort, "", "output file") + _ = cmd.MarkFlagRequired(flag.Output) + cmd.Flags().BoolVarP(&opts.ReplaceOutput, flag.ReplaceOutput, flag.ReplaceOutputShort, false, + "replace output file if exists") + cmd.Flags().BoolVarP(&opts.Watch, flag.Watch, flag.WatchShort, false, + "keeps the plugin running and watches the input file for changes") +} diff --git a/internal/convert/adv2v2.go b/internal/convert/adv2v2.go new file mode 100644 index 0000000..6c5f19a --- /dev/null +++ b/internal/convert/adv2v2.go @@ -0,0 +1,15 @@ +package convert + +import "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/hcl" + +// AdvancedClusterToV2 transforms all mongodbatlas_advanced_cluster resource definitions in a +// Terraform configuration file from SDKv2 schema to TPF (Terraform Plugin Framework) schema. +// All other resources and data sources are left untouched. +// TODO: Not implemented yet. +func AdvancedClusterToV2(config []byte) ([]byte, error) { + parser, err := hcl.GetParser(config) + if err != nil { + return nil, err + } + return parser.Bytes(), nil +} diff --git a/internal/convert/adv2v2_test.go b/internal/convert/adv2v2_test.go new file mode 100644 index 0000000..9ee1e32 --- /dev/null +++ b/internal/convert/adv2v2_test.go @@ -0,0 +1,13 @@ +package convert_test + +import ( + "testing" + + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/convert" +) + +func TestAdvancedClusterToV2(t *testing.T) { + runConvertTests(t, "adv2v2", func(testName string, inConfig []byte) ([]byte, error) { + return convert.AdvancedClusterToV2(inConfig) + }) +} diff --git a/internal/convert/convert.go b/internal/convert/clu2adv.go similarity index 100% rename from internal/convert/convert.go rename to internal/convert/clu2adv.go diff --git a/internal/convert/clu2adv_test.go b/internal/convert/clu2adv_test.go new file mode 100644 index 0000000..6f1ab7f --- /dev/null +++ b/internal/convert/clu2adv_test.go @@ -0,0 +1,15 @@ +package convert_test + +import ( + "strings" + "testing" + + "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/convert" +) + +func TestClusterToAdvancedCluster(t *testing.T) { + runConvertTests(t, "clu2adv", func(testName string, inConfig []byte) ([]byte, error) { + includeMoved := strings.Contains(testName, "includeMoved") + return convert.ClusterToAdvancedCluster(inConfig, includeMoved) + }) +} diff --git a/internal/convert/convert_test.go b/internal/convert/convert_test.go index 79487ce..da2b1cc 100644 --- a/internal/convert/convert_test.go +++ b/internal/convert/convert_test.go @@ -6,20 +6,21 @@ import ( "strings" "testing" - "github.com/mongodb-labs/atlas-cli-plugin-terraform/internal/convert" "github.com/sebdah/goldie/v2" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestClusterToAdvancedCluster(t *testing.T) { +// runConvertTests runs common conversion tests with the given test directory and convert function +func runConvertTests(t *testing.T, cmdName string, convert func(testName string, inConfig []byte) ([]byte, error)) { + t.Helper() const ( - root = "testdata/clu2adv" inSuffix = ".in.tf" outSuffix = ".out.tf" errFilename = "errors.json" ) + root := filepath.Join("testdata", cmdName) fs := afero.NewOsFs() errMap := make(map[string]string) errContent, err := afero.ReadFile(fs, filepath.Join(root, errFilename)) @@ -38,8 +39,7 @@ func TestClusterToAdvancedCluster(t *testing.T) { t.Run(testName, func(t *testing.T) { inConfig, err := afero.ReadFile(fs, inputFile) require.NoError(t, err) - includeMoved := strings.Contains(testName, "includeMoved") - outConfig, err := convert.ClusterToAdvancedCluster(inConfig, includeMoved) + outConfig, err := convert(testName, inConfig) if err == nil { g.Assert(t, testName, outConfig) } else { diff --git a/internal/convert/testdata/adv2v2/basic.in.tf b/internal/convert/testdata/adv2v2/basic.in.tf new file mode 100644 index 0000000..36c41a2 --- /dev/null +++ b/internal/convert/testdata/adv2v2/basic.in.tf @@ -0,0 +1,4 @@ +resource "mongodbatlas_advanced_cluster" "basic" { + name = "basic" + // TODO: missing fields as transformation is not implemented yet +} diff --git a/internal/convert/testdata/adv2v2/basic.out.tf b/internal/convert/testdata/adv2v2/basic.out.tf new file mode 100644 index 0000000..36c41a2 --- /dev/null +++ b/internal/convert/testdata/adv2v2/basic.out.tf @@ -0,0 +1,4 @@ +resource "mongodbatlas_advanced_cluster" "basic" { + name = "basic" + // TODO: missing fields as transformation is not implemented yet +} diff --git a/internal/convert/testdata/adv2v2/configuration_file_error.in.tf b/internal/convert/testdata/adv2v2/configuration_file_error.in.tf new file mode 100644 index 0000000..a742c35 --- /dev/null +++ b/internal/convert/testdata/adv2v2/configuration_file_error.in.tf @@ -0,0 +1,2 @@ +resource this is an invalid HCL configuration file + diff --git a/internal/convert/testdata/adv2v2/errors.json b/internal/convert/testdata/adv2v2/errors.json new file mode 100644 index 0000000..3de0218 --- /dev/null +++ b/internal/convert/testdata/adv2v2/errors.json @@ -0,0 +1,3 @@ +{ + "configuration_file_error": "failed to parse Terraform config file" +} diff --git a/manifest.template.yml b/manifest.template.yml index 5406fb9..439ca13 100644 --- a/manifest.template.yml +++ b/manifest.template.yml @@ -8,5 +8,5 @@ binary: $BINARY commands: terraform: description: Utilities for Terraform's MongoDB Atlas Provider - tf: - description: Alias for the terraform command + aliases: + - tf diff --git a/test/e2e/adv2v2_test.go b/test/e2e/adv2v2_test.go new file mode 100644 index 0000000..13a460f --- /dev/null +++ b/test/e2e/adv2v2_test.go @@ -0,0 +1,11 @@ +package e2e_test + +import ( + "testing" + + "github.com/mongodb-labs/atlas-cli-plugin-terraform/test/e2e" +) + +func TestAdvancedClusterToV2(t *testing.T) { + e2e.RunTests(t, "adv2v2", nil) +} diff --git a/test/e2e/clu2adv_test.go b/test/e2e/clu2adv_test.go index edf2f1b..fa47e41 100644 --- a/test/e2e/clu2adv_test.go +++ b/test/e2e/clu2adv_test.go @@ -1,73 +1,19 @@ package e2e_test import ( - "os" "testing" "github.com/mongodb-labs/atlas-cli-plugin-terraform/test/e2e" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestClu2AdvParams(t *testing.T) { - cwd, err := os.Getwd() - require.NoError(t, err) - var ( - prefix = cwd + "/testdata/" - fileIn = prefix + "clu2adv.in.tf" - fileOut = prefix + "clu2adv.out.tf" - fileExpected = prefix + "clu2adv.expected.tf" - fileExpectedMoved = prefix + "clu2adv.expected_moved.tf" - fileUnexisting = prefix + "clu2adv.unexisting.tf" - fs = afero.NewOsFs() - ) - tests := map[string]struct { - expectedErrContains string - assertFunc func(t *testing.T) - args []string - }{ - "no params": { - expectedErrContains: "required flag(s) \"file\", \"output\" not set", - }, - "no input file": { - args: []string{"--output", fileOut}, - expectedErrContains: "required flag(s) \"file\" not set", - }, - "no output file": { - args: []string{"--file", fileIn}, - expectedErrContains: "required flag(s) \"output\" not set", - }, - "unexisting input file": { - args: []string{"--file", fileUnexisting, "--output", fileOut}, - expectedErrContains: "file must exist: " + fileUnexisting, - }, - "existing output file without replaceOutput flag": { - args: []string{"--file", fileIn, "--output", fileExpected}, - expectedErrContains: "file must not exist: " + fileExpected, - }, - "basic use": { - args: []string{"--file", fileIn, "--output", fileOut}, - assertFunc: func(t *testing.T) { t.Helper(); e2e.CompareFiles(t, fs, fileOut, fileExpected) }, - }, +func TestClusterToAdvancedCluster(t *testing.T) { + files := e2e.GetTestFiles(t, "clu2adv") + fileExpectedMoved := files.GetCustomFilePath("expected_moved.tf") + extraTests := map[string]e2e.TestCase{ "include moved": { - args: []string{"--file", fileIn, "--output", fileOut, "--includeMoved"}, - assertFunc: func(t *testing.T) { t.Helper(); e2e.CompareFiles(t, fs, fileOut, fileExpectedMoved) }, + Args: []string{"--file", files.FileIn, "--output", files.FileOut, "--includeMoved"}, + Assert: func(t *testing.T) { t.Helper(); e2e.CompareFiles(t, files.Fs, files.FileOut, fileExpectedMoved) }, }, } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - resp, err := e2e.RunClu2Adv(tc.args...) - assert.Equal(t, tc.expectedErrContains == "", err == nil) - if err == nil { - assert.Empty(t, resp) - if tc.assertFunc != nil { - tc.assertFunc(t) - } - } else { - assert.Contains(t, resp, tc.expectedErrContains) - } - _ = fs.Remove(fileOut) // Ensure the output file does not exist in case it was generated in some test case - }) - } + e2e.RunTests(t, "clu2adv", extraTests) } diff --git a/test/e2e/e2e_helper.go b/test/e2e/e2e_helper.go index bbff09f..2749d0a 100644 --- a/test/e2e/e2e_helper.go +++ b/test/e2e/e2e_helper.go @@ -2,6 +2,7 @@ package e2e import ( "context" + "os" "os/exec" "testing" @@ -17,8 +18,8 @@ func RunTF(args ...string) (string, error) { return string(resp), err } -func RunClu2Adv(args ...string) (string, error) { - args = append([]string{"clu2adv"}, args...) +func RunTFCommand(command string, args ...string) (string, error) { + args = append([]string{command}, args...) return RunTF(args...) } @@ -30,3 +31,98 @@ func CompareFiles(t *testing.T, fs afero.Fs, file1, file2 string) { require.NoError(t, err2) assert.Equal(t, string(data1), string(data2)) } + +type TestFiles struct { + Fs afero.Fs + Prefix string + FileIn string + FileOut string + FileExpected string + FileUnexisting string + CmdName string +} + +// GetTestFiles creates a TestFiles struct with standard file paths for the given command name +func GetTestFiles(t *testing.T, cmdName string) *TestFiles { + t.Helper() + cwd, err := os.Getwd() + require.NoError(t, err) + + prefix := cwd + "/testdata/" + files := &TestFiles{ + Fs: afero.NewOsFs(), + CmdName: cmdName, + Prefix: prefix, + } + files.FileIn = files.GetCustomFilePath("in.tf") + files.FileOut = files.GetCustomFilePath("out.tf") + files.FileExpected = files.GetCustomFilePath("expected.tf") + files.FileUnexisting = files.GetCustomFilePath("unexisting.tf") + return files +} + +// GetCustomFilePath returns a file path using the testdata prefix +func (tf *TestFiles) GetCustomFilePath(suffix string) string { + return tf.Prefix + tf.CmdName + "." + suffix +} + +type TestCase struct { + ExpectedErrContains string + Assert func(t *testing.T) + Args []string +} + +// RunTests runs common parameter validation tests for both commands. Specific tests can be provided in extraTests. +func RunTests(t *testing.T, cmdName string, extraTests map[string]TestCase) { + t.Helper() + files := GetTestFiles(t, cmdName) + commonTests := map[string]TestCase{ + "no params": { + ExpectedErrContains: "required flag(s) \"file\", \"output\" not set", + }, + "no input file": { + Args: []string{"--output", files.FileOut}, + ExpectedErrContains: "required flag(s) \"file\" not set", + }, + "no output file": { + Args: []string{"--file", files.FileIn}, + ExpectedErrContains: "required flag(s) \"output\" not set", + }, + "unexisting input file": { + Args: []string{"--file", files.FileUnexisting, "--output", files.FileOut}, + ExpectedErrContains: "file must exist: " + files.FileUnexisting, + }, + "existing output file without replaceOutput flag": { + Args: []string{"--file", files.FileIn, "--output", files.FileExpected}, + ExpectedErrContains: "file must not exist: " + files.FileExpected, + }, + "basic use": { + Args: []string{"--file", files.FileIn, "--output", files.FileOut}, + Assert: func(t *testing.T) { t.Helper(); CompareFiles(t, files.Fs, files.FileOut, files.FileExpected) }, + }, + } + + allTests := make(map[string]TestCase) + for name, test := range commonTests { + allTests[name] = test + } + for name, test := range extraTests { + allTests[name] = test + } + + for name, tc := range allTests { + t.Run(name, func(t *testing.T) { + resp, err := RunTFCommand(cmdName, tc.Args...) + assert.Equal(t, tc.ExpectedErrContains == "", err == nil) + if err == nil { + assert.Empty(t, resp) + if tc.Assert != nil { + tc.Assert(t) + } + } else { + assert.Contains(t, resp, tc.ExpectedErrContains) + } + _ = files.Fs.Remove(files.FileOut) // Ensure output file does not exist in case it was generated in some test case + }) + } +} diff --git a/test/e2e/testdata/adv2v2.expected.tf b/test/e2e/testdata/adv2v2.expected.tf new file mode 100644 index 0000000..36c41a2 --- /dev/null +++ b/test/e2e/testdata/adv2v2.expected.tf @@ -0,0 +1,4 @@ +resource "mongodbatlas_advanced_cluster" "basic" { + name = "basic" + // TODO: missing fields as transformation is not implemented yet +} diff --git a/test/e2e/testdata/adv2v2.in.tf b/test/e2e/testdata/adv2v2.in.tf new file mode 100644 index 0000000..36c41a2 --- /dev/null +++ b/test/e2e/testdata/adv2v2.in.tf @@ -0,0 +1,4 @@ +resource "mongodbatlas_advanced_cluster" "basic" { + name = "basic" + // TODO: missing fields as transformation is not implemented yet +}