DEV Community

Cover image for Administering Terraform Cloud using GitHub Actions
Mattias Fjellström
Mattias Fjellström

Posted on • Originally published at mattias.engineer

Administering Terraform Cloud using GitHub Actions

Terraform Cloud is a platform for running Terraform remotely in HashiCorp's own cloud environment. It simplifies working with Terraform in teams and organizations. Terraform Cloud stores your state files for you1. You can connect your Terraform Cloud workspace to you Git repository and have Terraform Cloud automatically apply any updates that are committed to your repository.

When you set up a new workspace in Terraform Cloud you can select a repository, a directory in your repository where your Terraform configuration resides, as well as which git branch the workspace should create resources from. A workspace can only be connected to a single git repository and branch. If you want to create several instances of your infrastructure you have to create several workspaces in Terraform Cloud. This is all fine, but is there a way to automate creating new ephemeral workspaces for development branches that you only keep alive for a short while? For this you have to use the Terraform Cloud API. There is no CLI available, which is a bit surprising because HashiCorp has an abundance of different CLI tools for their various products2. HashiCorp has kindly developed a Go client for Terraform Cloud/Enterprise, so we are not forced to do all the work ourselves.

Let us use the Go client to develop something useful! One thing that I have searched in vain for is a way to work with Terraform Cloud using GitHub Actions. I have a dream of being able to automatically create Terraform Cloud workspaces for development branches that are also automatically deleted when I merge my changes to the main branch. So in this post I will walk through how I did just that!

This is not a post introducing Terraform Cloud. If you want to use the different GitHub actions I present in this post you will need to have a working Terraform Cloud organization and be familiar with how to create a token for authenticating to Terraform Cloud. If you want to learn more and get started I recommend you go to developer.hashicorp.com/terraform/cloud-docs

Writing custom actions in Go

I recently got certified in GitHub Actions3 and as part of that I learned about creating my own custom actions. I used these skills to create a number of actions that perform small tasks in Terraform Cloud. All my actions can be found here:

  • Set up environment variables for Terraform Cloud: this action takes a number of inputs specifying an API token for Terraform Cloud, the name of the organization you own, the name of a project (a collection of workspaces), and the name of the workspace you want to use. It then makes these different settings available as environment variables for the following actions.
  • Create a new workspace: this action creates a new workspace. Version 1 of this action (the current version) is very opinionated about what a workspace should look like.
  • Delete an existing workspace: this action deletes an existing workspace. It first starts a destroy run, so that all infrastructure created by Terraform is deleted, and then it proceeds to delete the workspace itself.
  • Apply a variable set to a workspace: this action applies an existing variable set to a workspace. A variable set is a collection of variables that you want to reuse in many workspaces. Good candidates for variable sets include credentials to cloud providers or any other type of credentials to various platforms.
  • Start a new run in a workspace: this action triggers a plan-and-apply run in the selected workspace.

In this post I will go through the details of how I created the action for creating new workspaces.

First of all, if you want to create a custom action there are three different options to choose from:

  • JavaScript action: write the action in JavaScript.
  • Docker action: write the action in any language of framework you can dream of, and package it as a Docker image.
  • Composite action: write an action consisting of a number of steps executing shell commands or scripts.

Since HashiCorp has a Go client for Terraform Cloud the natural choice for me was to write my action code in Go, thus selecting the Docker action type of action.

Metadata for an action

The metadata for a custom action specifies things like its name, a description, what inputs it takes, what outputs it produces, and properties about what the action consists of. This data is configured in a file called action.yaml (or action.yml). For my custom action the action.yaml file looks like this:

name: Create Terraform Cloud workspace
author: Mattias Fjellström (mattias.fjellstrom [at] gmail.com)
description: Create a new workspace in Terraform Cloud

inputs:
  organization:
    description: Organization name
  project:
    description: Project name
  workspace:
    description: Desired workspace name
  repository:
    description: GitHub repository name
    default: ${{ github.repository }}
  branch:
    description: Git branch name to trigger runs from
  directory:
    description: Repository directory name containing Terraform configuration

runs:
  using: docker
  image: Dockerfile
  args:
    - -organization
    - ${{ inputs.organization }}
    - -project
    - ${{ inputs.project }}
    - -workspace
    - ${{ inputs.workspace }}
    - -repository
    - ${{ inputs.repository }}
    - -working_directory
    - ${{ inputs.directory }}
    - -branch
    - ${{ inputs.branch }}
Enter fullscreen mode Exit fullscreen mode

The name, description, and author parts are self-explanatory. The inputs section specifies six parameters:

  • organization and project are used to identify where in Terraform Cloud this workspace should be placed. Workspaces in Terraform Cloud are grouped into projects, and all projects are part of an organization. Usually you have one organization, several projects, and even more workspaces.
  • workspace is used to configure the desired name of the workspace that will be created.
  • repository, branch, and directory are all used to identify where the Terraform configuration lives.

The last section is runs. This is where I configure that this action is using: docker and I provide a( path to my Dockerfile (which incidentally is just Dockerfile), and I provide my inputs as arguments to my Docker image4.

Dockerizing my action

The Dockerfile for my action is for a simple Go application without any fancy features:

FROM golang:1.20.2-alpine
WORKDIR /app
COPY ./ ./
RUN go build -o /bin/app main.go
ENTRYPOINT ["app"]
Enter fullscreen mode Exit fullscreen mode

The Go code

The meat of the action itself is located in main.go. I will go through the relevant parts of the code piece by piece. To parse the input arguments I use the flag package:

var organizationName string
var projectName string
var workspaceName string
var repositoryName string
var workingDirectory string
var branchName string

func init() {
    flag.StringVar(&organizationName, "organization", "", "Organization name")
    flag.StringVar(&projectName, "project", "", "Project name")
    flag.StringVar(&workspaceName, "workspace", "", "Desired workspace name")
    flag.StringVar(&repositoryName, "repository", "", "Git repository")
    flag.StringVar(&workingDirectory, "working_directory", "", "Directory containing Terraform configuration")
    flag.StringVar(&branchName, "branch", "", "Git branch name")
}

func main() {
    flag.Parse()

    // ...
}
Enter fullscreen mode Exit fullscreen mode

I define the various flags in an init() function that runs before my main() function. The first step of the main() function is to parse the flags to obtain whatever values were provided (if any). After that I go through each flag to check if a value was provided, and if not I fall back to using environment variables if they are defined. An example for the organization name looks like this:

if organizationName == "" {
    log.Println("No organization name provided, will fall back to environment variable")

    _, ok := os.LookupEnv(ENV_TERRAFORM_CLOUD_ORGANIZATION)
    if !ok {
        log.Fatal("Organization name must be provided as input or as environment variable")
    }

    organizationName = os.Getenv(ENV_TERRAFORM_CLOUD_ORGANIZATION)
    log.Println("Organization name read from environment variable")
}
Enter fullscreen mode Exit fullscreen mode

The actual name of the environment variable is stored as a const named ENV_TERRAFORM_CLOUD_ORGANIZATION. To read environment variables I use the os package.

Once the input has been parsed I look for the Terraform Cloud API token that I require to be set as an environment variable, and if I find it I go on to initialize the Terraform Cloud Go client:

token, ok := os.LookupEnv(ENV_TERRAFORM_CLOUD_TOKEN)
if !ok || token == "" {
    log.Fatalf("%s environment variable must be set with a valid token", ENV_TERRAFORM_CLOUD_TOKEN)
}

config := &tfe.Config{
    Token:             token,
    RetryServerErrors: true,
}

client, err := tfe.NewClient(config)
if err != nil {
    log.Fatal(err)
}
Enter fullscreen mode Exit fullscreen mode

I have imported the Go client as import tfe "github.com/hashicorp/go-tfe" and aliased it to tfe.

Next there are two parts where I search for various things:

  • I need to find the correct project to use. I must access the project ID, not its name. The Go client does not allow me to lookup a project by name so I have to list all the projects and pick the one with the matching name. It is a bit tedious, and I will not show the code for that here. See the GitHub repository for details.
  • I also need to find the correct connection to GitHub. This is a requirement for my action, there must be an GitHub App installation connecting my Terraform Cloud organization with my GitHub organization. A future improvement on this action would be to allow other git-connections. However, similarly as with projects I must list the available GitHub Apps and find the one that is for the same GitHub organization that is currently running the action. I won't include that code here either, but the details are available in the GitHub repository.

With all of that out of the way the last part of the code creates the workspace:

_, err = client.Workspaces.Create(ctx, organizationName, tfe.WorkspaceCreateOptions{
    Type:             "workspaces",
    Name:             tfe.String(workspaceName),
    AutoApply:        tfe.Bool(true),
    WorkingDirectory: tfe.String(workingDirectory),
    VCSRepo: &tfe.VCSRepoOptions{
        Branch:            tfe.String(branchName),
        Identifier:        tfe.String(repositoryName),
        GHAInstallationID: gitHubApplication.ID,
    },
    Project: project,
})
if err != nil {
    log.Fatal(err)
}
Enter fullscreen mode Exit fullscreen mode

I configure the workspace using the input arguments and the project and gitHubApplication that I searched for and hopefully found. I set AutoApply to true because I want the infrastructure to be created automatically without manual approvals.

That was a short walkthrough of the code, the full source is available at GitHub.

Writing a GitHub workflow that creates and deletes Terraform Cloud workspaces

With all of my custom actions written and published in their own GitHub repositories I can go on and actually use them for something.

I had an idea that I wanted to create a GitHub workflow where if I create a new pull-request a new Terraform Cloud workspace is created automatically, and an initial run is triggered. Then when the pull-request is closed the workspace and corresponding infrastructure should be removed.

A workflow that does exactly this might look like the following:

name: Sample Terraform Cloud administration for pull requests

on:
  # trigger when pull requests are opened or closed
  pull_request:
    types:
      - opened
      - closed

# set some convenience environment variables
env:
  ORGANIZATION: my-terraform-cloud-organization
  PROJECT: my-terraform-cloud-project

jobs:

  # job for creating a workspace for new pull requests
  create-workspace:
    if: ${{ github.event.action == 'opened' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      # set up environment variables for terraform cloud
      - uses: mattias-fjellstrom/tfc-setup@v1
        with:
          token: ${{ secrets.TERRAFORM_CLOUD_TOKEN }}
          organization: ${{ env.ORGANIZATION }}
          project: ${{ env.PROJECT }}
          workspace: my-application-${{ github.head_ref }}
      # create the workspace (the action I went through above!)
      - uses: mattias-fjellstrom/tfc-create-workspace@v1
        with:
          directory: terraform
          branch: ${{ github.head_ref }}
      # apply a variable set with azure credentials to the workspace
      - uses: mattias-fjellstrom/tfc-apply-variable-set@v1
        with:
          variable_set: azure-credentials
      # trigger an initial run
      - uses: mattias-fjellstrom/tfc-start-run@v1

  # job for deleting a workspace for closed pull requests
  delete-workspace:
    if: ${{ github.event.action == 'closed' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      # set up environment variables for terraform cloud
      - uses: mattias-fjellstrom/tfc-setup@v1
        with:
          token: ${{ secrets.TERRAFORM_CLOUD_TOKEN }}
          organization: ${{ env.ORGANIZATION }}
          project: ${{ env.PROJECT }}
          workspace: my-application-${{ github.head_ref }}
      # delete infrastructure and terraform cloud workspace
      - uses: mattias-fjellstrom/tfc-delete-workspace@v1
Enter fullscreen mode Exit fullscreen mode

I have added a few comments to the workflow to explain what the different actions are doing. A few details I wish to highlight:

  • I have stored an API token to Terraform Cloud in a GitHub Actions secret named TERRAFORM_CLOUD_TOKEN. You are currently required to provide a token to the mattias-fjellstrom/tfc-setup@v1 action for the other actions to work. This is similar to how the azure/login@v1 action works.
  • I set the workspace name to my-application-${{ github.head_ref }}. If I create a new branch named feature-1 and I open a pull request to merge this branch into my main branch I will get a new Terraform Cloud workspace named my-application-feature-1.
  • In the mattias-fjellstrom/tfc-create-workspace@v1 action I specify directory: terraform. This is because in my imaginary repository I have placed all my Terraform files (.tf) in a directory named terraform. If I did not provide a value for the directory the root directory of my repository would have been used instead.
  • In the mattias-fjellstrom/tfc-apply-variable-set@v1 action I specify that a variable set named azure-credentials should be applied to my workspace. This variable set must exist from before, it is not created automatically by this action.
  • An important thing to keep in mind is that the action mattias-fjellstrom/tfc-delete-workspace@v1 could potentially take some time. As a first step it will start a destroy run, removing all the infrastructure it has created. Depending on the size of this infrastructure it will take a corresponding amount of time to delete.

All in all I am happy with the state of theses actions right now. If I need additional feature I might add them later on. However, most of all I hope HashiCorp will do one (or both) of the following things:

  1. Create a CLI for Terraform Cloud
  2. Create official GitHub Actions for administering Terraform Cloud

We will see what the future brings!


  1. That is, if you want Terraform Cloud to do so! You can still use any remote backend you wish to use. However, personally I find the state storage to be one of the best features of Terraform Cloud. No need to configure a remote backend and keep the credentials for that backend in order. Unless you have a specific use-case that requires a different state backend I would recommend that you trust Terraform Cloud to keep your state files. 

  2. I suspect there will come a CLI at some point, maybe even some time soon. Remember: you heard it here first! 

  3. This certification is available for GitHub Partners. 

  4. I had severe problems figuring out how to provide flag arguments to my running Docker image as arguments. I thought I could write each flag as one item in the list of arguments like this - -organization ${{ inputs.organization }} but it turns out that if you do that then Docker will interpret the whole thing as the flag (not just the -organization part). 

Top comments (0)