diff --git a/CHANGELOG.md b/CHANGELOG.md index a36db11..65a778f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## (Unreleased) +ENHANCEMENTS: + +* Supports `dynamic` block for `tags` and `labels` + ## 1.0.0 (Mar 6, 2025) NOTES: diff --git a/README.md b/README.md index a504bd2..71e0378 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This repository contains the Atlas CLI plugin for [Terraform's MongoDB Atlas Provider](https://registry.terraform.io/providers/mongodb/mongodbatlas/latest/docs). It has the following commands to help with your Terraform configurations: -- **clusterToAdvancedCluster**: Convert a `mongodbatlas_cluster` Terraform configuration to `mongodbatlas_advanced_cluster` (preview provider v2). +- **clusterToAdvancedCluster**: Convert a `mongodbatlas_cluster` Terraform configuration to `mongodbatlas_advanced_cluster` (preview provider 2.0.0). ## Installation @@ -15,12 +15,22 @@ Install the plugin by running: ```bash atlas plugin install github.com/mongodb-labs/atlas-cli-plugin-terraform ``` + +If you have it installed and want to update it to the latest version, run: +```bash +atlas plugin update mongodb-labs/atlas-cli-plugin-terraform +``` + +If you want to see the list of installed plugins or check if this plugin is installed, run: +```bash +atlas plugin list +``` -## Convert mongodbatlas_cluster to mongodbatlas_advanced_cluster (preview provider v2) +## Convert mongodbatlas_cluster to mongodbatlas_advanced_cluster (preview provider 2.0.0) ### Usage -**Note**: In order to use the **Preview for MongoDB Atlas Provider v2** of `mongodbatlas_advanced_cluster`, you need to set the environment variable `MONGODB_ATLAS_PREVIEW_PROVIDER_V2_ADVANCED_CLUSTER` to `true`. +**Note**: In order to use the **Preview for MongoDB Atlas Provider 2.0.0** of `mongodbatlas_advanced_cluster`, you need to set the environment variable `MONGODB_ATLAS_PREVIEW_PROVIDER_V2_ADVANCED_CLUSTER` to `true`. If you want to convert a Terraform configuration from `mongodbatlas_cluster` to `mongodbatlas_advanced_cluster`, use the following command: ```bash @@ -38,12 +48,36 @@ If you want to overwrite the output file if it exists, or even use the same outp You can use the `--watch` or the `-w` flag to keep the plugin running and watching for changes in the input file. You can have input and output files open in an editor and see easily how changes to the input file affect the output file. +You can find [here](https://github.com/mongodb-labs/atlas-cli-plugin-terraform/tree/main/internal/convert/testdata/clu2adv) some examples of input files (suffix .in.tf) and the corresponding output files (suffix .out.tf). + +### Dynamic blocks + +`dynamic` blocks are used to generate multiple nested blocks based on a set of values. +Given the different ways of using dynamic blocks, we recommend reviewing the output and making sure it fits your needs. + +#### Dynamic blocks in tags and labels + +You can use `dynamic` blocks for `tags` and `labels`. You can also combine the use of dynamic blocks in `tags` and `labels` with individual blocks in the same cluster definition, e.g.: +```hcl +tags { + key = "environment" + value = var.environment +} +dynamic "tags" { + for_each = var.tags + content { + key = tags.key + value = replace(tags.value, "/", "_") + } +} +``` + ### Limitations - The plugin doesn't support `regions_config` without `electable_nodes` as there can be some issues with `priority` when they only have `analytics_nodes` and/or `electable_nodes`. - [`priority`](https://registry.terraform.io/providers/mongodb/mongodbatlas/latest/docs/resources/cluster#priority-1) is required in `regions_config` and must be a numeric [literal expression](https://developer.hashicorp.com/nomad/docs/job-specification/hcl2/expressions#literal-expressions) between 7 and 1, e.g. `var.priority` is not supported. This is to allow reordering them by descending priority as this is expected in `mongodbatlas_advanced_cluster`. - [`num_shards`](https://registry.terraform.io/providers/mongodb/mongodbatlas/latest/docs/resources/cluster#num_shards-2) in `replication_specs` must be a numeric [literal expression](https://developer.hashicorp.com/nomad/docs/job-specification/hcl2/expressions#literal-expressions), e.g. `var.num_shards` is not supported. This is to allow creating a `replication_specs` element per shard in `mongodbatlas_advanced_cluster`. -- `dynamic` blocks to generate `replication_specs`, `regions_config`, etc. are not supported. +- `dynamic` blocks are currently supported only for `tags` and `labels`. **Coming soon**: support for `replication_specs` and `regions_config`. ## Contributing diff --git a/internal/cli/clu2adv/clu2adv.go b/internal/cli/clu2adv/clu2adv.go index 92bd509..691a5d3 100644 --- a/internal/cli/clu2adv/clu2adv.go +++ b/internal/cli/clu2adv/clu2adv.go @@ -10,8 +10,8 @@ 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", + 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 { diff --git a/internal/convert/const_names.go b/internal/convert/const_names.go index 65fccf1..7352d18 100644 --- a/internal/convert/const_names.go +++ b/internal/convert/const_names.go @@ -51,4 +51,7 @@ const ( nMoved = "moved" nFrom = "from" nTo = "to" + nDynamic = "dynamic" + nForEach = "for_each" + nContent = "content" ) diff --git a/internal/convert/convert.go b/internal/convert/convert.go index 65a7f1f..74ff298 100644 --- a/internal/convert/convert.go +++ b/internal/convert/convert.go @@ -1,8 +1,8 @@ package convert import ( - "errors" "fmt" + "slices" "sort" "strconv" "strings" @@ -36,13 +36,17 @@ const ( commentRemovedOld = "Note: Remember to remove or comment out the old cluster definitions." ) +var ( + dynamicBlockAllowList = []string{nTags, nLabels} +) + type attrVals struct { req map[string]hclwrite.Tokens opt map[string]hclwrite.Tokens } // ClusterToAdvancedCluster transforms all mongodbatlas_cluster definitions in a -// Terraform configuration file into mongodbatlas_advanced_cluster schema v2 definitions. +// Terraform configuration file into mongodbatlas_advanced_cluster schema 2.0.0. // All other resources and data sources are left untouched. // Note: hclwrite.Tokens are used instead of cty.Value so expressions with interpolations like var.region can be preserved. // cty.Value only supports literal expressions. @@ -224,6 +228,50 @@ func fillReplicationSpecs(resourceb *hclwrite.Body, root attrVals) error { } func fillTagsLabelsOpt(resourceb *hclwrite.Body, name string) error { + tokensDynamic, err := extractTagsLabelsDynamicBlock(resourceb, name) + if err != nil { + return err + } + tokensIndividual, err := extractTagsLabelsIndividual(resourceb, name) + if err != nil { + return err + } + if tokensDynamic != nil && tokensIndividual != nil { + resourceb.SetAttributeRaw(name, hcl.TokensFuncMerge(tokensDynamic, tokensIndividual)) + return nil + } + if tokensDynamic != nil { + resourceb.SetAttributeRaw(name, tokensDynamic) + } + if tokensIndividual != nil { + resourceb.SetAttributeRaw(name, tokensIndividual) + } + return nil +} + +func extractTagsLabelsDynamicBlock(resourceb *hclwrite.Body, name string) (hclwrite.Tokens, error) { + d, err := getDynamicBlock(resourceb, name) + if err != nil || d.forEach == nil { + return nil, err + } + key := d.content.Body().GetAttribute(nKey) + value := d.content.Body().GetAttribute(nValue) + if key == nil || value == nil { + return nil, fmt.Errorf("dynamic block %s: %s or %s not found", name, nKey, nValue) + } + keyExpr := replaceDynamicBlockExpr(key, name, nKey) + valueExpr := replaceDynamicBlockExpr(value, name, nValue) + collectionExpr := hcl.GetAttrExpr(d.forEach) + forExpr := fmt.Sprintf("for key, value in %s : %s => %s", collectionExpr, keyExpr, valueExpr) + tokens := hcl.TokensObjectFromExpr(forExpr) + if keyExpr == nKey && valueExpr == nValue { // expression can be simplified and use for_each expression + tokens = hcl.TokensFromExpr(collectionExpr) + } + resourceb.RemoveBlock(d.block) + return tokens, nil +} + +func extractTagsLabelsIndividual(resourceb *hclwrite.Body, name string) (hclwrite.Tokens, error) { var ( file = hclwrite.NewEmptyFile() fileb = file.Body() @@ -237,16 +285,16 @@ func fillTagsLabelsOpt(resourceb *hclwrite.Body, name string) error { key := block.Body().GetAttribute(nKey) value := block.Body().GetAttribute(nValue) if key == nil || value == nil { - return fmt.Errorf("%s: %s or %s not found", name, nKey, nValue) + return nil, fmt.Errorf("%s: %s or %s not found", name, nKey, nValue) } setKeyValue(fileb, key, value) resourceb.RemoveBlock(block) found = true } - if found { - resourceb.SetAttributeRaw(name, hcl.TokensObject(fileb)) + if !found { + return nil, nil } - return nil + return hcl.TokensObject(fileb), nil } func fillBlockOpt(resourceb *hclwrite.Body, name string) { @@ -394,13 +442,45 @@ func getResourceLabel(resource *hclwrite.Block) string { func checkDynamicBlock(body *hclwrite.Body) error { for _, block := range body.Blocks() { - if block.Type() == "dynamic" { - return errors.New("dynamic blocks are not supported") + name := getResourceName(block) + if block.Type() != nDynamic || slices.Contains(dynamicBlockAllowList, name) { + continue } + return fmt.Errorf("dynamic blocks are not supported for %s", name) } return nil } +type dynamicBlock struct { + block *hclwrite.Block + forEach *hclwrite.Attribute + content *hclwrite.Block +} + +func getDynamicBlock(body *hclwrite.Body, name string) (dynamicBlock, error) { + for _, block := range body.Blocks() { + if block.Type() != nDynamic || name != getResourceName(block) { + continue + } + blockb := block.Body() + forEach := blockb.GetAttribute(nForEach) + if forEach == nil { + return dynamicBlock{}, fmt.Errorf("dynamic block %s: attribute %s not found", name, nForEach) + } + content := blockb.FirstMatchingBlock(nContent, nil) + if content == nil { + return dynamicBlock{}, fmt.Errorf("dynamic block %s: block %s not found", name, nContent) + } + return dynamicBlock{forEach: forEach, block: block, content: content}, nil + } + return dynamicBlock{}, nil +} + +func replaceDynamicBlockExpr(attr *hclwrite.Attribute, blockName, attrName string) string { + expr := hcl.GetAttrExpr(attr) + return strings.ReplaceAll(expr, fmt.Sprintf("%s.%s", blockName, attrName), attrName) +} + func setKeyValue(body *hclwrite.Body, key, value *hclwrite.Attribute) { keyStr, err := hcl.GetAttrString(key, "") if err == nil { diff --git a/internal/convert/testdata/clu2adv/dynamic_tags_labels.in.tf b/internal/convert/testdata/clu2adv/dynamic_tags_labels.in.tf new file mode 100644 index 0000000..20964a7 --- /dev/null +++ b/internal/convert/testdata/clu2adv/dynamic_tags_labels.in.tf @@ -0,0 +1,159 @@ +resource "mongodbatlas_cluster" "simplified" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + provider_name = "AWS" + provider_instance_size_name = "M10" + replication_specs { + num_shards = 1 + regions_config { + region_name = "US_EAST_1" + electable_nodes = 3 + priority = 7 + } + } + dynamic "tags" { + for_each = var.tags + content { // simplified version where var can be used directly + key = tags.key + value = tags.value + } + } +} + +resource "mongodbatlas_cluster" "expression" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + provider_name = "AWS" + provider_instance_size_name = "M10" + replication_specs { + num_shards = 1 + regions_config { + region_name = "US_EAST_1" + electable_nodes = 3 + priority = 7 + } + } + dynamic "tags" { + for_each = local.tags + content { // using expressions + key = tags.key + value = replace(tags.value, "/", "_") + } + } +} + +resource "mongodbatlas_cluster" "simplified_individual" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + provider_name = "AWS" + provider_instance_size_name = "M10" + replication_specs { + num_shards = 1 + regions_config { + region_name = "US_EAST_1" + electable_nodes = 3 + priority = 7 + } + } + tags { // using individual tags apart from simplified version in dynamic tags + key = "tag1" + value = var.tag1val + } + dynamic "tags" { + for_each = var.tags + content { // simplified version where var can be used directly + key = tags.key + value = tags.value + } + } + tags { + key = "tag 2" + value = var.tag2val + } +} + +resource "mongodbatlas_cluster" "expression_individual" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + provider_name = "AWS" + provider_instance_size_name = "M10" + replication_specs { + num_shards = 1 + regions_config { + region_name = "US_EAST_1" + electable_nodes = 3 + priority = 7 + } + } + tags { // using individual tags apart from expressions in dynamic tags + key = "tag1" + value = var.tag1val + } + dynamic "tags" { + for_each = var.tags + content { // using expressions + key = tags.key + value = replace(tags.value, "/", "_") + } + } + tags { + key = "tag 2" + value = var.tag2val + } +} + +resource "mongodbatlas_cluster" "full_example" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + provider_name = "AWS" + provider_instance_size_name = "M10" + replication_specs { + num_shards = 1 + regions_config { + region_name = "US_EAST_1" + electable_nodes = 3 + priority = 7 + } + } + labels { + key = "label1" + value = "label1val" + } + labels { + key = "label2" + value = data.my_resource.my_data.value + } + dynamic "labels" { + for_each = local.tags + content { + key = labels.key + value = labels.value + } + } + tags { + key = "environment" + value = "dev" + } + tags { + key = var.tag_key # non-literal values are supported and enclosed in parentheses + value = var.tag_value + } + dynamic "tags" { + for_each = var.tags + content { + key = tags.key + value = replace(tags.value, "/", "_") + } + } + lifecycle { + precondition { + condition = local.use_new_replication_specs || !(var.auto_scaling_disk_gb_enabled && var.disk_size > 0) + error_message = "Must use either auto_scaling_disk_gb_enabled or disk_size, not both." + } + } +} diff --git a/internal/convert/testdata/clu2adv/dynamic_tags_labels.out.tf b/internal/convert/testdata/clu2adv/dynamic_tags_labels.out.tf new file mode 100644 index 0000000..3e78a8f --- /dev/null +++ b/internal/convert/testdata/clu2adv/dynamic_tags_labels.out.tf @@ -0,0 +1,161 @@ +resource "mongodbatlas_advanced_cluster" "simplified" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + replication_specs = [ + { + region_configs = [ + { + provider_name = "AWS" + region_name = "US_EAST_1" + priority = 7 + electable_specs = { + node_count = 3 + instance_size = "M10" + } + } + ] + } + ] + tags = var.tags + + # Generated by atlas-cli-plugin-terraform. + # Please confirm that all references to this resource are updated. +} + +resource "mongodbatlas_advanced_cluster" "expression" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + replication_specs = [ + { + region_configs = [ + { + provider_name = "AWS" + region_name = "US_EAST_1" + priority = 7 + electable_specs = { + node_count = 3 + instance_size = "M10" + } + } + ] + } + ] + tags = { + for key, value in local.tags : key => replace(value, "/", "_") + } + + # Generated by atlas-cli-plugin-terraform. + # Please confirm that all references to this resource are updated. +} + +resource "mongodbatlas_advanced_cluster" "simplified_individual" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + replication_specs = [ + { + region_configs = [ + { + provider_name = "AWS" + region_name = "US_EAST_1" + priority = 7 + electable_specs = { + node_count = 3 + instance_size = "M10" + } + } + ] + } + ] + tags = merge( + var.tags, + { + tag1 = var.tag1val + "tag 2" = var.tag2val + } + ) + + # Generated by atlas-cli-plugin-terraform. + # Please confirm that all references to this resource are updated. +} + +resource "mongodbatlas_advanced_cluster" "expression_individual" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + replication_specs = [ + { + region_configs = [ + { + provider_name = "AWS" + region_name = "US_EAST_1" + priority = 7 + electable_specs = { + node_count = 3 + instance_size = "M10" + } + } + ] + } + ] + tags = merge( + { + for key, value in var.tags : key => replace(value, "/", "_") + }, + { + tag1 = var.tag1val + "tag 2" = var.tag2val + } + ) + + # Generated by atlas-cli-plugin-terraform. + # Please confirm that all references to this resource are updated. +} + +resource "mongodbatlas_advanced_cluster" "full_example" { + project_id = var.project_id + name = "cluster" + cluster_type = "REPLICASET" + lifecycle { + precondition { + condition = local.use_new_replication_specs || !(var.auto_scaling_disk_gb_enabled && var.disk_size > 0) + error_message = "Must use either auto_scaling_disk_gb_enabled or disk_size, not both." + } + } + replication_specs = [ + { + region_configs = [ + { + provider_name = "AWS" + region_name = "US_EAST_1" + priority = 7 + electable_specs = { + node_count = 3 + instance_size = "M10" + } + } + ] + } + ] + tags = merge( + { + for key, value in var.tags : key => replace(value, "/", "_") + }, + { + environment = "dev" + (var.tag_key) = var.tag_value + } + ) + labels = merge( + local.tags, + { + label1 = "label1val" + label2 = data.my_resource.my_data.value + } + ) + + # Generated by atlas-cli-plugin-terraform. + # Please confirm that all references to this resource are updated. +} diff --git a/internal/hcl/hcl.go b/internal/hcl/hcl.go index 66ab072..46f661c 100644 --- a/internal/hcl/hcl.go +++ b/internal/hcl/hcl.go @@ -3,6 +3,7 @@ package hcl import ( "fmt" "strconv" + "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" @@ -36,6 +37,11 @@ func SetAttrExpr(body *hclwrite.Body, attrName, expresion string) { body.SetAttributeRaw(attrName, tokens) } +// GetAttrExpr returns the expression of an attribute as a string. +func GetAttrExpr(attr *hclwrite.Attribute) string { + return strings.TrimSpace(string(attr.Expr().BuildTokens(nil).Bytes())) +} + // SetAttrInt sets an attribute to a number. func SetAttrInt(body *hclwrite.Body, attrName string, number int) { tokens := hclwrite.Tokens{ @@ -79,22 +85,14 @@ func GetAttrString(attr *hclwrite.Attribute, errPrefix string) (string, error) { // TokensArray creates an array of objects. func TokensArray(bodies []*hclwrite.Body) hclwrite.Tokens { - ret := hclwrite.Tokens{ - {Type: hclsyntax.TokenOBrack, Bytes: []byte("[")}, - {Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, - } + ret := hclwrite.Tokens{tokenNewLine} + tokens := make([]hclwrite.Tokens, 0) for i := range bodies { - ret = append(ret, TokensObject(bodies[i])...) - if i < len(bodies)-1 { - ret = append(ret, - &hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte(",")}, - &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}) - } + tokens = append(tokens, TokensObject(bodies[i])) } - ret = append(ret, - &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, - &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte("]")}) - return ret + ret = append(ret, joinTokens(tokens...)...) + ret = append(ret, tokenNewLine) + return encloseBrackets(ret) } // TokensArraySingle creates an array of one object. @@ -104,15 +102,31 @@ func TokensArraySingle(body *hclwrite.Body) hclwrite.Tokens { // TokensObject creates an object. func TokensObject(body *hclwrite.Body) hclwrite.Tokens { - tokens := RemoveLeadingNewline(body.BuildTokens(nil)) - ret := hclwrite.Tokens{ - {Type: hclsyntax.TokenOBrace, Bytes: []byte("{")}, - {Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, - } - ret = append(ret, tokens...) - ret = append(ret, - &hclwrite.Token{Type: hclsyntax.TokenCBrace, Bytes: []byte("}")}) - return ret + tokens := hclwrite.Tokens{tokenNewLine} + tokens = append(tokens, RemoveLeadingNewline(body.BuildTokens(nil))...) + return encloseBraces(tokens) +} + +// TokensFromExpr creates the tokens for an expression provided as a string. +func TokensFromExpr(expr string) hclwrite.Tokens { + return hclwrite.Tokens{{Type: hclsyntax.TokenIdent, Bytes: []byte(expr)}} +} + +// TokensObjectFromExpr creates an object with an expression. +func TokensObjectFromExpr(expr string) hclwrite.Tokens { + tokens := hclwrite.Tokens{tokenNewLine} + tokens = append(tokens, TokensFromExpr(expr)...) + tokens = append(tokens, tokenNewLine) + return encloseBraces(tokens) +} + +// TokensFuncMerge creates the tokens for the HCL merge function. +func TokensFuncMerge(tokens ...hclwrite.Tokens) hclwrite.Tokens { + params := hclwrite.Tokens{tokenNewLine} + params = append(params, joinTokens(tokens...)...) + params = append(params, tokenNewLine) + ret := hclwrite.Tokens{{Type: hclsyntax.TokenIdent, Bytes: []byte("merge")}} + return append(ret, encloseParens(params)...) } // RemoveLeadingNewline removes the first newline if it exists to make the output prettier. @@ -133,9 +147,46 @@ func AppendComment(body *hclwrite.Body, comment string) { // GetParser returns a parser for the given config and checks HCL syntax is valid func GetParser(config []byte) (*hclwrite.File, error) { - parser, diags := hclwrite.ParseConfig(config, "", hcl.Pos{Line: 1, Column: 1}) + parser, diags := hclwrite.ParseConfig(config, "", hcl.InitialPos) if diags.HasErrors() { return nil, fmt.Errorf("failed to parse Terraform config file: %s", diags.Error()) } return parser, nil } + +var tokenNewLine = &hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte("\n")} + +// joinTokens joins multiple tokens with commas and newlines. +func joinTokens(tokens ...hclwrite.Tokens) hclwrite.Tokens { + ret := hclwrite.Tokens{} + for i := range tokens { + ret = append(ret, tokens[i]...) + if i < len(tokens)-1 { + ret = append(ret, + &hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte(",")}, + tokenNewLine) + } + } + return ret +} + +// encloseParens encloses tokens with parentheses, ( ). +func encloseParens(tokens hclwrite.Tokens) hclwrite.Tokens { + ret := hclwrite.Tokens{{Type: hclsyntax.TokenOParen, Bytes: []byte("(")}} + ret = append(ret, tokens...) + return append(ret, &hclwrite.Token{Type: hclsyntax.TokenCParen, Bytes: []byte(")")}) +} + +// encloseBraces encloses tokens with curly braces, { }. +func encloseBraces(tokens hclwrite.Tokens) hclwrite.Tokens { + ret := hclwrite.Tokens{{Type: hclsyntax.TokenOBrace, Bytes: []byte("{")}} + ret = append(ret, tokens...) + return append(ret, &hclwrite.Token{Type: hclsyntax.TokenCBrace, Bytes: []byte("}")}) +} + +// encloseBrackets encloses tokens with square brackets, [ ]. +func encloseBrackets(tokens hclwrite.Tokens) hclwrite.Tokens { + ret := hclwrite.Tokens{{Type: hclsyntax.TokenOBrack, Bytes: []byte("[")}} + ret = append(ret, tokens...) + return append(ret, &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte("]")}) +}