Golang: Handling Terraform Files

Coal
5 min readMay 30, 2023

--

In the previous article, I talked about the reasons why I adopted a state file as the foundation for the framework I am developing.

In today’s article, we will see in practice how to manipulate Terraform state files using Golang.

Before we begin, make sure you have Go installed on your machine. You can obtain Go at https://golang.org/dl/. Additionally, you will need to have Terraform installed on your system. You can find installation instructions at https://www.terraform.io/downloads.html.

Preparing the Setup

To start, I will create a new directory for the Go project and then initialize a new Go module.

mkdir edit-terraform
cd edit-terraform
go mod init edit-terraform

This will create a go.mod file in the directory.

Next, we will create a directory called “test” and add the source.tf file where we will add the Terraform code.

# test/source.tf
variable "name" {
type = string
default = "World"
}

terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "2.4.0"
}
}
}

resource "local_file" "foo" {
content = "Hello, ${var.name}"
filename = "${path.module}/hello.txt"
}

As we can see, the above code creates a file called “hello.txt” with the content “Hello, “ concatenated with the variable “name”. By default, the value of this variable is set to “World”. Therefore, when we execute the command “terraform init” (to initialize Terraform) and “terraform plan” (to plan the changes), we will have the following output:

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create

Terraform will perform the following actions:

# local_file.foo will be created
+ resource "local_file" "foo" {
+ content = "Hello, World"
+ content_base64sha256 = (known after apply)
+ content_base64sha512 = (known after apply)
+ content_md5 = (known after apply)
+ content_sha1 = (known after apply)
+ content_sha256 = (known after apply)
+ content_sha512 = (known after apply)
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "./hello.txt"
+ id = (known after apply)
}

Plan: 1 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform
apply" now.

This output shows the changes that are being made to the application state.

Opening the file

Now we will create the state.go file inside the state directory, and we will start working on the code responsible for manipulating the Terraform file.
Let’s create a function called Open that will receive the path to the Terraform file to be opened and return a pointer to an object representing the file’s contents.

// state/state.go
package state

import (
"errors"
"io/ioutil"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclwrite"
)

func Open(filepath string) (*hclwrite.File, error) {
content, err := ioutil.ReadFile(filepath)
if err != nil {
return nil, err
}
file, diags := hclwrite.ParseConfig(content, filepath, hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
err := errors.New("an error occurred")
if err != nil {
return nil, err
}
}
return file, nil
}

Let’s take a closer look at the code above.

content, err := ioutil.ReadFile(filepath)
if err != nil {
return nil, err
}

The first thing we do is read the desired Terraform file using the built-in library provided by the language.

file, diags := hclwrite.ParseConfig(content, filepath, hcl.Pos{Line: 1, Column: 1})

Next, we use hclwrite to convert the file into a Go data structure. This library is officially provided by HashiCorp.

Manipulating the file

Let’s also add the UpdateDefaultValue method, which will take three parameters: the object representing the file, the variable name, and the new value.

// state/state.go
package state

import (
"errors"
"io/ioutil"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclwrite"
)

...

func UpdateDefaultValue(file *hclwrite.File, name string, value string) bool {
for _, block := range file.Body().Blocks() {
labels := block.Labels()
if block.Type() == "variable" && len(labels) > 0 && name == labels[0] {
if block.Body().GetAttribute("default") != nil {
block.Body().SetAttributeValue("default", cty.StringVal(value))
return true
}
}
}
return false
}

The above code scans through the body of the file, searching for variables that match the desired pattern. If a match is found, it replaces the value of that variable in the file with the provided value.

Writing the File

Lastly, let’s add a function to save the edited content back to the file. To do this, we will pass the file path and the file object to the function.

// state/state.go
package state

import (
"errors"
"io/ioutil"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclwrite"
)

...

func Save(filename string, file *hclwrite.File) error {
if err := ioutil.WriteFile(filename, file.Bytes(), 0644); err != nil {
return err
}
return nil
}

As we can see, once again we use ioutil to write the new content to the file.

Testing

Now let’s create the main.go file and use the package we just created.

package main

import (
"fmt"

"example/state"
)

func main() {

var filename = "tests/example.tf"
file, err := state.Open(filename)
if err != nil {
fmt.Println(err)
}

ok := state.UpdateDefaultValue(file, "name", "You")
if !ok {
err := state.Save(filename, file)
if err != nil {
fmt.Printf("Erro ao salvar as alterações no arquivo %s: %s\n", filename, err)
}
}

fmt.Printf("File Updated!\n", filename)
}

As we can see, the process is quite simple. We open the file, edit a variable, and then save the file. Now we can execute our code and check the changes.

go run main.go
File Updated!

If we open the contents of the test/source.tf file, we will notice that the file has been updated on line 3, and where there was previously the value World, now there is the value You.

variable "name" {
type = set(string)
default = "You"
}

Now I’m going to update my code to change the value of the name variable and perform the same test. And if the plan command is executed again, we can see that the state is being planned with the new value.

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create

Terraform will perform the following actions:

# local_file.foo will be created
+ resource "local_file" "foo" {
+ content = "Hello, You"
+ content_base64sha256 = (known after apply)
+ content_base64sha512 = (known after apply)
+ content_md5 = (known after apply)
+ content_sha1 = (known after apply)
+ content_sha256 = (known after apply)
+ content_sha512 = (known after apply)
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "./hello.txt"
+ id = (known after apply)
}

Plan: 1 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform
apply" now.

--

--