diff --git a/internal/hcl/hcl.go b/internal/hcl/hcl.go index de6ce1f..3a14ae8 100644 --- a/internal/hcl/hcl.go +++ b/internal/hcl/hcl.go @@ -2,22 +2,38 @@ package hcl import ( "fmt" + "strconv" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" ) const ( - resourceType = "resource" - cluster = "mongodbatlas_cluster" - advCluster = "mongodbatlas_advanced_cluster" + resourceType = "resource" + cluster = "mongodbatlas_cluster" + advCluster = "mongodbatlas_advanced_cluster" + nameReplicationSpecs = "replication_specs" + nameRegionConfigs = "region_configs" + nameElectableSpecs = "electable_specs" + nameProviderRegionName = "provider_region_name" + nameRegionName = "region_name" + nameProviderName = "provider_name" + nameBackingProviderName = "backing_provider_name" + nameProviderInstanceSizeName = "provider_instance_size_name" + nameInstanceSize = "instance_size" + nameClusterType = "cluster_type" + namePriority = "priority" + + errFreeCluster = "free cluster (because no " + nameReplicationSpecs + ")" ) // 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. +// Note: hclwrite.Tokens are used instead of cty.Value so expressions like var.region can be preserved. +// cty.Value only supports resolved values. func ClusterToAdvancedCluster(config []byte) ([]byte, error) { parser, err := getParser(config) if err != nil { @@ -30,10 +46,15 @@ func ClusterToAdvancedCluster(config []byte) ([]byte, error) { continue } resourceBody := resource.Body() - - // TODO: Do the full transformation labels[0] = advCluster resource.SetLabels(labels) + + if isFreeTier(resourceBody) { + if err := fillFreeTier(resourceBody); err != nil { + return nil, err + } + } + resourceBody.AppendNewline() appendComment(resourceBody, "Generated by atlas-cli-plugin-terraform.") appendComment(resourceBody, "Please confirm that all references to this resource are updated.") @@ -41,20 +62,89 @@ func ClusterToAdvancedCluster(config []byte) ([]byte, error) { 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()) +func isFreeTier(body *hclwrite.Body) bool { + return body.FirstMatchingBlock(nameReplicationSpecs, nil) == nil +} + +func fillFreeTier(body *hclwrite.Body) error { + const ( + valClusterType = "REPLICASET" + valPriority = 7 + ) + body.SetAttributeValue(nameClusterType, cty.StringVal(valClusterType)) + regionConfig := hclwrite.NewEmptyFile() + regionConfigBody := regionConfig.Body() + setAttrInt(regionConfigBody, "priority", valPriority) + if err := moveAttribute(nameProviderRegionName, nameRegionName, body, regionConfigBody, errFreeCluster); err != nil { + return err } - return parser, nil + if err := moveAttribute(nameProviderName, nameProviderName, body, regionConfigBody, errFreeCluster); err != nil { + return err + } + if err := moveAttribute(nameBackingProviderName, nameBackingProviderName, body, regionConfigBody, errFreeCluster); err != nil { + return err + } + electableSpec := hclwrite.NewEmptyFile() + if err := moveAttribute(nameProviderInstanceSizeName, nameInstanceSize, body, electableSpec.Body(), errFreeCluster); err != nil { + return err + } + regionConfigBody.SetAttributeRaw(nameElectableSpecs, tokensObject(electableSpec)) + + replicationSpec := hclwrite.NewEmptyFile() + replicationSpec.Body().SetAttributeRaw(nameRegionConfigs, tokensArrayObject(regionConfig)) + body.SetAttributeRaw(nameReplicationSpecs, tokensArrayObject(replicationSpec)) + return nil +} + +func moveAttribute(fromAttrName, toAttrName string, fromBody, toBody *hclwrite.Body, errPrefix string) error { + attr := fromBody.GetAttribute(fromAttrName) + if attr == nil { + return fmt.Errorf("%s: attribute %s not found", errPrefix, fromAttrName) + } + fromBody.RemoveAttribute(fromAttrName) + toBody.SetAttributeRaw(toAttrName, attr.Expr().BuildTokens(nil)) + return nil +} + +func setAttrInt(body *hclwrite.Body, attrName string, number int) { + tokens := hclwrite.Tokens{ + {Type: hclsyntax.TokenNumberLit, Bytes: []byte(strconv.Itoa(number))}, + } + body.SetAttributeRaw(attrName, tokens) +} + +func tokensArrayObject(file *hclwrite.File) hclwrite.Tokens { + ret := hclwrite.Tokens{ + {Type: hclsyntax.TokenOBrack, Bytes: []byte("[")}, + } + ret = append(ret, tokensObject(file)...) + ret = append(ret, + &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte("]")}) + return ret +} + +func tokensObject(file *hclwrite.File) hclwrite.Tokens { + ret := hclwrite.Tokens{ + {Type: hclsyntax.TokenOBrack, Bytes: []byte("{")}, + {Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, + } + ret = append(ret, file.BuildTokens(nil)...) + ret = append(ret, + &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte("}")}) + return ret } func appendComment(body *hclwrite.Body, comment string) { tokens := hclwrite.Tokens{ - &hclwrite.Token{ - Type: hclsyntax.TokenComment, - Bytes: []byte("# " + comment + "\n"), - }, + &hclwrite.Token{Type: hclsyntax.TokenComment, Bytes: []byte("# " + comment + "\n")}, } body.AppendUnstructuredTokens(tokens) } + +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 +} diff --git a/internal/hcl/hcl_test.go b/internal/hcl/hcl_test.go index 1d09ac2..5f6619f 100644 --- a/internal/hcl/hcl_test.go +++ b/internal/hcl/hcl_test.go @@ -1,6 +1,7 @@ package hcl_test import ( + "encoding/json" "path/filepath" "strings" "testing" @@ -14,14 +15,20 @@ import ( func TestClusterToAdvancedCluster(t *testing.T) { const ( - root = "testdata/clu2adv" - inSuffix = ".in.tf" - outSuffix = ".out.tf" + root = "testdata/clu2adv" + inSuffix = ".in.tf" + outSuffix = ".out.tf" + errFilename = "errors.json" ) + fs := afero.NewOsFs() + errMap := make(map[string]string) + errContent, err := afero.ReadFile(fs, filepath.Join(root, errFilename)) + require.NoError(t, err) + err = json.Unmarshal(errContent, &errMap) + require.NoError(t, err) 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) @@ -31,7 +38,12 @@ func TestClusterToAdvancedCluster(t *testing.T) { inConfig, err := afero.ReadFile(fs, inputFile) require.NoError(t, err) outConfig, err := hcl.ClusterToAdvancedCluster(inConfig) - require.NoError(t, err) - g.Assert(t, testName, outConfig) + if err == nil { + g.Assert(t, testName, outConfig) + } else { + errMsg, found := errMap[testName] + assert.True(t, found, "error not found for test %s", testName) + assert.Contains(t, err.Error(), errMsg) + } } } diff --git a/internal/hcl/testdata/clu2adv/basic.in.tf b/internal/hcl/testdata/clu2adv/basic.in.tf deleted file mode 100644 index d58a798..0000000 --- a/internal/hcl/testdata/clu2adv/basic.in.tf +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 5c68226..0000000 --- a/internal/hcl/testdata/clu2adv/basic.out.tf +++ /dev/null @@ -1,18 +0,0 @@ -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" -} diff --git a/internal/hcl/testdata/clu2adv/configuration_file_error.in.tf b/internal/hcl/testdata/clu2adv/configuration_file_error.in.tf new file mode 100644 index 0000000..a742c35 --- /dev/null +++ b/internal/hcl/testdata/clu2adv/configuration_file_error.in.tf @@ -0,0 +1,2 @@ +resource this is an invalid HCL configuration file + diff --git a/internal/hcl/testdata/clu2adv/errors.json b/internal/hcl/testdata/clu2adv/errors.json new file mode 100644 index 0000000..99d16e1 --- /dev/null +++ b/internal/hcl/testdata/clu2adv/errors.json @@ -0,0 +1,4 @@ +{ + "configuration_file_error": "failed to parse Terraform config file", + "free_cluster_missing_attribute": "free cluster (because no replication_specs): attribute backing_provider_name not found" +} diff --git a/internal/hcl/testdata/clu2adv/free_cluster_missing_attribute.in.tf b/internal/hcl/testdata/clu2adv/free_cluster_missing_attribute.in.tf new file mode 100644 index 0000000..2c3f37b --- /dev/null +++ b/internal/hcl/testdata/clu2adv/free_cluster_missing_attribute.in.tf @@ -0,0 +1,19 @@ +resource "resource1" "res1" { + name = "name1" +} + +resource "mongodbatlas_cluster" "free_cluster" { # comment in the resource + # comment in own line in the beginning + count = local.use_free_cluster ? 1 : 0 + project_id = var.project_id # inline comment kept + name = var.cluster_name + # comment in own line in the middle is deleted + provider_name = "TENANT" # inline comment for attribute moved is not kept + provider_region_name = var.region + provider_instance_size_name = "M0" + # comment in own line at the end happens before replication_specs +} + +data "mongodbatlas_cluster" "cluster2" { + name = "name4" +} diff --git a/internal/hcl/testdata/clu2adv/free_cluster_with_count.in.tf b/internal/hcl/testdata/clu2adv/free_cluster_with_count.in.tf new file mode 100644 index 0000000..fef02c0 --- /dev/null +++ b/internal/hcl/testdata/clu2adv/free_cluster_with_count.in.tf @@ -0,0 +1,20 @@ +resource "resource1" "res1" { + name = "name1" +} + +resource "mongodbatlas_cluster" "free_cluster" { # comment in the resource + # comment in own line in the beginning + count = local.use_free_cluster ? 1 : 0 + project_id = var.project_id # inline comment kept + name = var.cluster_name + # comment in own line in the middle is deleted + provider_name = "TENANT" # inline comment for attribute moved is not kept + backing_provider_name = "AWS" + provider_region_name = var.region + provider_instance_size_name = "M0" + # comment in own line at the end happens before replication_specs +} + +data "mongodbatlas_cluster" "cluster2" { + name = "name4" +} diff --git a/internal/hcl/testdata/clu2adv/free_cluster_with_count.out.tf b/internal/hcl/testdata/clu2adv/free_cluster_with_count.out.tf new file mode 100644 index 0000000..92b7f68 --- /dev/null +++ b/internal/hcl/testdata/clu2adv/free_cluster_with_count.out.tf @@ -0,0 +1,30 @@ +resource "resource1" "res1" { + name = "name1" +} + +resource "mongodbatlas_advanced_cluster" "free_cluster" { # comment in the resource + # comment in own line in the beginning + count = local.use_free_cluster ? 1 : 0 + project_id = var.project_id # inline comment kept + name = var.cluster_name + # comment in own line at the end happens before replication_specs + cluster_type = "REPLICASET" + replication_specs = [{ + region_configs = [{ + priority = 7 + region_name = var.region + provider_name = "TENANT" + backing_provider_name = "AWS" + electable_specs = { + instance_size = "M0" + } + }] + }] + + # Generated by atlas-cli-plugin-terraform. + # Please confirm that all references to this resource are updated. +} + +data "mongodbatlas_cluster" "cluster2" { + name = "name4" +}