Terratest is a Go library that makes it easier to write automated tests for the infrastructure code. Terratest provides us a variety of helper functions and patterns to test our infrastructure easily.

In this post, we're gonna develop a simple Terratest Module for Azure Container Registry (ACR)

We're gonna follow the steps below;


Forking the official repo

Let's go to the official Terratest repo (https://github.com/gruntwork-io/terratest)

Gruntwork Terratest repo screenshot

Fork it to your own GitHub account.

Forked Terratest repo screenshot

And clone it to your development environment.

If you're a Microsoft employee, let aztfmod team (or my manager Richard Guthrie, or my fellow team-mate Hattan Shobokshi or myself - Engin Polat) know, so you can directly use aztfmod/terratest repo.

We're gonna use GitHub Codespaces throughout this blog post.


Adding the module

First thing first, let's run the following command to download missing dependencies;

go mod tidy

Now we're ready to add the module, to do that, create a file in the modules/ folder;

touch modules/azure/acr.go

There is a subfolder for Azure related modules, modules/azure

Let's put the module into the azure package by adding the following line at the beginning of the file;

package azure

Start simple by creating a function that returns the *containerregistry.Registry structure, for given Azure Subscription Id, Resource Group Name and ACR (Azure Container Registry) Name;

func GetACRClient(t *testing.T, resourceName string, resGroupName string, subscriptionID string) *containerregistry.Registry {
}

This function will allow Terratest users to get the underlying Registry structure easily.

In the future we might need to add more functions, so let's create an internal facing function that has the same signature (accepts the same parameters and return the same type);

func getACRClientE(resourceName string, resGroupName string, subscriptionID string) (*containerregistry.Registry, error) {
  subscriptionID, err := getTargetAzureSubscription(subscriptionID)
  if err != nil {
    return nil, err
  }

  managedServicesClient := containerregistry.NewRegistriesClient(subscriptionID)
  authorizer, err := NewAuthorizer()

  if err != nil {
    return nil, err
  }

  managedServicesClient.Authorizer = *authorizer

  resource, err := managedServicesClient.Get(context.Background(), resGroupName, resourceName)
  if err != nil {
    return nil, err
  }

  return &resource, nil
}

In this function, first, we're checking if the provided subscription is valid;

subscriptionID, err := getTargetAzureSubscription(subscriptionID)
if err != nil {
  return nil, err
}

Then creating the Authorizer and setting it as the Authorizer of the underlying service client;

managedServicesClient := containerregistry.NewRegistriesClient(subscriptionID)
authorizer, err := NewAuthorizer()

if err != nil {
  return nil, err
}

managedServicesClient.Authorizer = *authorizer

If everything went well up until this point, we can get the Container Registry instance by calling the Get() method of the client;

resource, err := managedServicesClient.Get(context.Background(), resGroupName, resourceName)
if err != nil {
  return nil, err
}

return &resource, nil

As a go rule, if the function starts with lower-case character, it means the function is private and cannot be accessible from outside of the package, but if the function starts with upper-case character, it means the function is public and can be accessible from outside of the package.

This is the main difference between the following function signatures;

func GetACRClient(t *testing.T, resourceName string, resGroupName string, subscriptionID string) *containerregistry.Registry {
}

func getACRClientE(resourceName string, resGroupName string, subscriptionID string) (*containerregistry.Registry, error) {
}

Also, if function returns both the structure and an error, function name usually ends with capital E character, to make it easier to see the function may return an Error object;

func getACRClientE(resourceName string, resGroupName string, subscriptionID string) (*containerregistry.Registry, error) {
}

After having the private function, we can call it from the public function;

func GetACRClient(t *testing.T, resourceName string, resGroupName string, subscriptionID string) *containerregistry.Registry {
  resource, err := getACRClientE(resourceName, resGroupName, subscriptionID)

  require.NoError(t, err)

  return resource
}

Running the CI workflow to test the module

If you have access to an Azure Subscription (get a free Azure Subscription from: Create your Azure free account today) you can run the CI workflow on it. If you don't have access to an Azure Subscription, you can run the CI workflow on aztfmod/terratest repo.

aztfmod/terratest repo screenshot

Running CI workflow on your own Azure Subscription

Official Terratest repo and aztfmod/terratest fork has a GitHub Action in .github/workflows/ci.yml file.

If you fork one of those repos, .github/workflows/ci.yml file will be exist in your repo too.

CI workflow needs the following Secrets set up in the repo Settings;

  • AZURE_CREDENTIALS

To have permission to the Azure Subscription, to be able to provision "terraform script under test", you need to create a Service Principal by executing the following script in your Terminal;

You can generate the credential by executing following script;

az ad sp create-for-rbac --sdk-auth --name terratest_workflow

Output of this command is in json format, copy and paste it in a Secret, named, AZURE_CREDENTIALS

  • PAT

When the CI workflow finishes, it creates a comment in the PR, it's working against.

CI workflow uses PAT to create the comment.

To create the PAT, go to Personal Access Tokens page under Developer Settings menu.

Copy the generated PAT, and paste it in a Secret, named, PAT

Your for is ready to run the tests now, you can use the same guideline in the next section.

Running CI workflow on aztfmod/terratest repo

Since the CI workflow has workflow_dispatch trigger, you can go to GitHub Actions page and manually run the workflow.

Actors of the workflow

  • Source repo: official terratest repo (gruntwork-io/terratest)
  • Forked repo: (e.g., aztfmod/terratest, polatengin/terratest)
  • PR: created from forked repo, against source repo (gruntwork-io/terratest/pull/{PR NUMBER})

Flow of the workflow

  • Developer fork the source repo
  • Developer creates a branch in the forked repo
  • Developer does the development 😎
  • Developer creates a PR
  • Developer triggers the CI workflow manually with the following values;
    • Repo: name of the forked repo (e.g. xyz/terratest)
    • Branch: branch name on the forked repo (e.g. feature/adding-some-important-module)
    • Target repository: home of the target_pr, which is the source repo (gruntwork-io/terratest)
    • Target PR: pr number on the source repo (e.g. 14, 25, etc.)

Triggering CI workflow screenshot

References