Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 105 additions & 15 deletions internal/hcl/hcl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,31 +46,105 @@ func ClusterToAdvancedCluster(config []byte) ([]byte, error) {
continue
}
resourceBody := resource.Body()

// TODO: Do the full transformation
labels[0] = advCluster
resource.SetLabels(labels)

if isFreeTier(resourceBody) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool approach of starting with one specific case.
I guess some operations might be more generally applicable, like moving instance_size, but we'll find out once we add more cases!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I'll go now with one with num_shards

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.")
}
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 {
Copy link
Contributor

@EspenAlbert EspenAlbert Feb 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the abstraction signature and you don't need to worry about the type of the attribute!

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
}
24 changes: 18 additions & 6 deletions internal/hcl/hcl_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hcl_test

import (
"encoding/json"
"path/filepath"
"strings"
"testing"
Expand All @@ -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)
Expand All @@ -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)
}
}
}
15 changes: 0 additions & 15 deletions internal/hcl/testdata/clu2adv/basic.in.tf

This file was deleted.

18 changes: 0 additions & 18 deletions internal/hcl/testdata/clu2adv/basic.out.tf

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
resource this is an invalid HCL configuration file

4 changes: 4 additions & 0 deletions internal/hcl/testdata/clu2adv/errors.json
Original file line number Diff line number Diff line change
@@ -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"
}
Copy link
Collaborator Author

@lantoli lantoli Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errors file instead of having individual out files with just the error message, so it's easier to have multiple input files with errors and don't have many files, and have a place to see all error messages.

Original file line number Diff line number Diff line change
@@ -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"
}
20 changes: 20 additions & 0 deletions internal/hcl/testdata/clu2adv/free_cluster_with_count.in.tf
Original file line number Diff line number Diff line change
@@ -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"
}
30 changes: 30 additions & 0 deletions internal/hcl/testdata/clu2adv/free_cluster_with_count.out.tf
Original file line number Diff line number Diff line change
@@ -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"
}