30. 07. 2024 Lorenzo Candeago DevOps

Terraform Integration with Ansible

In this blog post we’ll try a tool that’s new to me, called Terraform, and see how easy it is to integrate it with Ansible starting with no knowledge of Terraform.

Terraform is a tool that allows you to automate resource provisioning; it uses HCL2 as the configuration language, and support has recently been added, via the ansible-provider, for some integration with Ansible.

For our test case we’ll build and “provision” containers on the local machine via the Docker provisioner in Terraform using Podman, create an Ansible inventory from Terraform and then use an Ansible Playbook to copy some files on the containers that will be served by a Python HTTP server.

Let’s start with the Dockerfile: for simplicity we’ll use Python’s http.server module that will serve the files that are found under the /app directory in the container.

FROM alpine
RUN apk add --update --no-cache python3
RUN mkdir /app
ENTRYPOINT ["python3", "-m", "http.server", "80", "--directory", "/app"]

Now let’s create our first Terraform file, called provider.tf. This will install the required providers once we run terraform init.

terraform {
  required_version = ">=1.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0.2"
    }
    ansible = {
      version = "~> 1.3.0"
      source  = "ansible/ansible"
    }
  }
}

We’ll create a separate variable.tf file that will contain our input variables: this allows us to validate the input variables even in complex ways, such as using a regex.

variable "n_containers" {
  type        = number
  description = "The number of containers to be created, max 3"
  validation {
    # regex(...) fails if it cannot find a match
    condition     = var.n_containers <= 3 && var.n_containers > 0
    error_message = "n_containers should be at most 3"
  }
}

variable "docker_image_name" {
    type        = string
    description = "The name of the docker image to be created"
    validation {
      condition     = can(regex("[a-zA-Z0-9][a-zA-Z0-9_.-]+", var.docker_image_name))
      error_message = "docker_image_name should match  [a-zA-Z0-9][a-zA-Z0-9_.-]+"
    }
}

Finally we can create the actual main.tf that will contain all the infrastructure code. Let’s split it into three sections: the first resource (a docker_image) will build the Dockerfile previously created and will keep the image locally (i.e., not push it to an external registry).

resource "docker_image" "test_http_server" {
  name         = "${var.docker_image_name}:latest"
  keep_locally = true
  build {
    context = ""
  }
}

Next we will create n_containers using the image built in the previous step. Each container we create will be indexed from 0 to the value of count and can be accessed via ${count.index} in string substitution or via [] syntax when we are picking an element from a list. For our toy sample container with index n we’ll expose port 80 on 808n.

resource "docker_container" "server" {
  count    = var.n_containers
  name     = "${var.docker_image_name}${count.index}"
  image    = docker_image.test_http_server.image_id
  must_run = true
  ports {
    external = "808${count.index}"
    internal = 80
  }
}

Finally, we’ll add to main.tf our resource that will export the Ansible inventory:

resource "ansible_host" "ansible_inventory" {
  count = var.n_containers
  name  = docker_container.server[count.index].name
  groups = ["http_server"]
  variables = {
    ansible_connection = "podman"
  }
}
Full main.tf
resource "docker_image" "test_http_server" {
name = "${var.docker_image_name}:latest"
keep_locally = true
build {
context = ""
}
}

resource "docker_container" "server" {
count = var.n_containers
name = "${var.docker_image_name}${count.index}"
image = docker_image.test_http_server.image_id
must_run = true
ports {
external = "808${count.index}"
internal = 80
}
}

resource "ansible_host" "ansible_inventory" {
count = var.n_containers
name = docker_container.server[count.index].name
groups = ["alpine"]
variables = {
ansible_connection = "podman"
}
}

After checking that Podman is actually running by checking the output of systemctl status –user podman.socket we can finally provision our containers:

terraform init
terraform apply

Terraform will ask for the values of the variables we defined and for a confirmation to start the provisioning. If we want to skip manual input of the variables, we can create a file with extension .tfvars where we can assign the values of our input variables

n_containers = 2
docker_image_name = "http_server_test"

and run the provisioner passing the varfile created:

terraform apply -var-file="http_server.tfvars"

Now that we’re able to provision our containers, let’s see how this integrates with Ansible.

We can use the Ansible collection cloud.terraform:

ansible-galaxy collection install cloud.terraform

which will provide us with a dynamic inventory plugin that can read the inventory we created in Terraform and is stored in Terraform’s state. Our Ansible inventory can be as simple as this:

---
plugin: cloud.terraform.terraform_provider

We can now check that the inventory works:

 ansible-inventory -i  inventory.yaml --list
{
    "_meta": {
        "hostvars": {
            "http_server_test0": {
                "ansible_connection": "podman"
            },
            "http_server_test1": {
                "ansible_connection": "podman"
            }
        }
    },
    "all": {
        "children": [
            "ungrouped",
            "alpine"
        ]
    },
    "alpine": {
        "hosts": [
            "http_server_test0",
            "http_server_test1"
        ]
    }
}

And now we can create a simple playbook that creates an empty file in the /app folder of the containers deployed:

- hosts:
    - all
  tasks:
    - name: Copy file to the container
      ansible.builtin.file:
        path: /app/file.txt
        state: touch

It’s time to run our playbook:

ansible-playbook -i ansible/inventory.yaml  ansible/playbook.yaml 

and check at localhost:8080 and localhost:8081 that the files have been created.

That’s it for this post, I hope you found it useful. The Ansible provisioner for Terraform is still in development and might be missing some features, but it’s already somewhat usable when integrating Terraform with Ansible.

These Solutions are Engineered by Humans

Did you find this article interesting? Are you an “under the hood” kind of person? We’re really big on automation and we’re always looking for people in a similar vein to fill roles like this one as well as other roles here at Würth Phoenix.

Lorenzo Candeago

Lorenzo Candeago

DevOps Engineer at Würth Phoenix

Author

Lorenzo Candeago

DevOps Engineer at Würth Phoenix

Leave a Reply

Your email address will not be published. Required fields are marked *

Archive