Introduction
goTerra
In a recent project, some of my fellow students and I developed a basic hosting provider that allows a user to spin up Docker containers on a remote server, which is realized by using Terraform locally on the server.
During this project, we developed a Go-based backend service that provided a REST API to the client and handled the connection to Terraform via RabbitMQ, authentication via Keycloak and data persistence with DynamoDB. As mentioned earlier, Terraform runs locally on the remote server and is fully automated by Go’s power. This sometimes became a challenge. This blog post briefly describes how we interacted with Terraform using Go and the problems we have encountered and those we have identified but not yet resolved, while working with Terraform.
Terraform
Terraform, an open-source tool created by HashiCorp for provisioning cloud infrastructure, allows describing infrastructure as code (IaC) in a simple, human-readable language called HCL (HashiCorp Configuration Language). This approach provides version-controllable, reusable, and collaborative means to manage infrastructure.
Traditionally, managing Terraform involves writing HCL files, running terraform init to initialize the Terraform working directory, terraform plan to create an execution plan, and terraform apply to apply the desired changes. This process, while straightforward, can become complex and time-consuming as your infrastructure grows.
This is where Go comes into play. In this blog post, I will share my experience of leveraging Go’s features to automate the process of managing Terraform through HashiCorp’s Terraform Go package.
Go: How to Interact with Terraform
Through HashiCorp’s Terraform go package, no direct interaction with the binary with OS commands is necessary.
Avoidance of requiring the provision of binaries
Terraform thought about that as well and does supply a procedure to download the required binaries through the code.
import (
"context"
version "github.com/hashicorp/go-version"
product "github.com/hashicorp/hc-install/product"
releases "github.com/hashicorp/hc-install/releases"
tfexec "github.com/hashicorp/terraform-exec/tfexec"
log "github.com/rs/zerolog/log"
)
func main() {
terraform_version := "1.7.1"
binary_install_dir := "/path/to/bins"
terraform_cwd_dir := "/path/to/terraformFiles"
// This specifies the exact version of the product, in this case Terraform, to install
installer := &releases.ExactVersion{
Product: product.Terraform,
Version: version.Must(version.NewVersion(terraform_version)),
InstallDir: binary_install_dir,
}
// This installs the product and returns the path to the binary
// The binary will be installed in the directory specified by InstallDir / binary_install_dir
execPath, err := installer.Install(context.TODO())
if err != nil { log.Error().Msgf("Error occured %s", err) }
// This ensures that the binary is installed and returns a Terraform struct
// The Terraform struct is used to interact with the Terraform binary
tf, err := tfexec.NewTerraform(terraform_cwd_dir, execPath)
if err != nil { log.Error().Msgf("Error occured %s", err) }
// This returns the version of the installed Terraform binary
tfVersion, _, err := tf.Version(context.TODO(), true)
if err != nil { log.Error().Msgf("Error occured %s", err) }
log.Debug().Msgf("Terraform installed in version: %s", tfVersion)
}
Invoking Terraform commands
The basic command palette of Terraform, e.g. init, plan, apply, and destroy, is easily accessible. As shown in the code example below, which extends the code above, this is made very easy.
var varFiles []string = []string{
"/path/to/terraform.tfvars.json",
}
// This runs the Terraform plan command, with the specified varFiles
// Sadly, there exists no generic PlanOption, so we have to create one for every Terraform command
tfPlanOpts := []tfexec.PlanOption{}
for _, varFile := range varFiles {
varFileOpt := tfexec.VarFile(varFile)
tfPlanOpts = append(tfPlanOpts, varFileOpt)
}
// This plans the Terraform configuration, which is the equivalent of running terraform plan
// This returns a boolean indicating whether the Terraform plan has a difference between the current state and the plan
plan_diff, err := tf.Plan(context.TODO(), tfPlanOpts...)
if err != nil { log.Error().Msgf("Error occured %s", err) }
if plan_diff {
log.Info().Msg("Terraform plan has a difference between the current state and the plan")
} else {
log.Info().Msg("Terraform plan has no difference between the current state and the plan")
}
tfApplyOpts := []tfexec.ApplyOption{}
for _, varFile := range varFiles {
varFileOpt := tfexec.VarFile(varFile)
tfApplyOpts = append(tfApplyOpts, varFileOpt)
}
// This applies the Terraform plan, which is the equivalent of running terraform apply
err = tf.Apply(context.TODO(), tfApplyOpts...)
if err != nil { log.Error().Msgf("Error occured %s", err) }
// This shows the Terraform state, which is the equivalent of running terraform show
state, err := tf.Show(context.TODO())
if err != nil { log.Error().Msgf("Error occured %s", err) }
log.Info().Msgf("New Terraform state %v", state)
Problems I encountered
No dependency for binaries
As already mentioned in “Avoidance of requiring the provision of binaries”, Terraform supplies a solution to automate the process of downloading the necessary binaries. This can also be done through HashiCorp’s tool, hc-install, available on GitHub.
Handling multiple Terraform states
When managing changes within the same environment concurrently in Terraform, consider using Terraform modules or leveraging multiple Terraform workspaces. Modules allow you to encapsulate and reuse configurations, promoting consistency and collaboration. Workspaces provide environmental isolation, making it easy to switch between different states. Additionally, changing the working directory dynamically using variables can help manage distinct environments effectively. Code-wise, this is easily appliable through changing Terraform’s current working directory, or, as can be seen in the code example, the value of the variable terraform_cwd_dir
.
Clear text credentials in Terraform files
When working with Terraform, you often need to define sensitive information such as passwords, API keys, or access tokens. However, storing these secrets directly in your Terraform configuration or environment files is not a best practice.
With the use of Terraform Vault, which is a secrets’ management tool that securely stores and manages sensitive data. Credentials can be dynamically accessed by defining placeholders (references) in the Terraform configuration. During runtime, Terraform communicates with the Vault to dynamically generate short-lived based on policies and roles or access existing credentials.
When retrieving secrets from the Vault, the data is still written in clear text to the following artifacts:
- State File: The state file generated by Terraform contains the secrets in plain text.
- Console Output: When Terraform runs, the secrets appear in the console output.
However, the issue of clear text credentials in the state file cannot be easily fixed.
To learn more about our project check out the following links:
- GitHub Repository: https://github.com/incompetent-hosting-provider/monorepo
- Blog post about monitoring with Prometheus: https://blog.mi.hdm-stuttgart.de/index.php/2024/02/29/why-system-monitoring-is-important-and-how-we-approached-it/
- Blog post about collections logs with Loki: https://blog.mi.hdm-stuttgart.de/index.php/2024/02/29/combining-zerolog-loki/
- Blog post about using Keycloak as an auth provider with Go: https://blog.mi.hdm-stuttgart.de/index.php/2024/02/29/using-keycloak-as-iam-for-our-hosting-provider-service/
Leave a Reply
You must be logged in to post a comment.