Terraform & Ansible

How to combine Ansible with Terraform to provision an AWS EC2 instance.

Provisioning an EC2 Instance

I had the need to provision an EC2 instance, and wanted to use the “infrastructure as code” approach.

I’ve used Cloud Formation in the past, but I wanted to experiment with Terraform. I’ve used cloud-init before, but I found it gets a bit unwieldy, and I didn’t know how well it would mix with Debian rather than Amazon Linux.

As I am familiar with Ansible my wish was to use it for both the initial provisioning, and for subsequent updates when I modified the playbooks.

I quickly found out that Terraform does not support Ansible as a first class provisioner, and only performs provisioning at resource creation.

The Terraform module registry lists 7 entries when searching for “ansible”, but I have no experience in selecting the “best” choice.

Approach

This blog post takes you through the pieces I used to run Ansible with an EC2 instance provisioned through Terraform.

The text below will exclude some non-essential details for brevity. The full example is available in the ec2-terraform-ansible repository.

Terraform

SSH Deployment Key

An SSH key is dynamically generated to use for deployment. This is both stored as an AWS key pair for use by the EC2 instance, and written to a local file for Ansible to use.

resource "tls_private_key" "deploy" {
  algorithm = "RSA"
}
resource "aws_key_pair" "deploy" {
  key_name_prefix = "deploy-demo_"
  public_key      = tls_private_key.deploy.public_key_openssh
}
resource "local_file" "deploy" {
  filename          = "deploy.pem"
  sensitive_content = tls_private_key.deploy.private_key_pem
  provisioner "local-exec" {
    command = "chmod 600 ${self.filename}"
  }
}

EC2 Instance

The creation of the EC2 instance is simple. It uses the “remote-exec” trick to wait for the instance to be available, and then runs ansible-playbook for the provisioning. The reason for the environment variable PUBLIC_IP is explained below.

resource "aws_instance" "demo" {
  depends_on = [local_file.deploy]

  ami           = var.ec2_ami_id
  instance_type = "t3a.nano"

  key_name = aws_key_pair.deploy.key_name

  vpc_security_group_ids = [
    aws_security_group.demo.id
  ]

  provisioner "remote-exec" {
    inline = ["hostname"]

    connection {
      type        = "ssh"
      host        = self.public_ip
      user        = "admin"
      private_key = tls_private_key.deploy.private_key_pem
    }
  }

  provisioner "local-exec" {
    command = "ansible-playbook site.yml"
    environment = {
      PUBLIC_IP = self.public_ip
    }
  }
}

Ansible

Configuration

The contents of ansible.cfg specify the location of a dynamic inventory script, the SSH key for deployment (generated by terraform) and the remote user (which is “admin” for Debian).

[defaults]

inventory = ./inventory

remote_user = admin
private_key_file = deploy.pem
host_key_checking = False

Dynamic Inventory

There is a general purpose dynamic inventory script for EC2, ec2.py, but at 1,713 lines it felt too general purpose (aka complicated) for what I felt I needed. I also don’t like copying large code chunks across – how do I keep it up to date?

The following is my simple inventory script.

#!/usr/bin/python3

from argparse import ArgumentParser
from os import environ
import json
import subprocess

def main():
    parser = ArgumentParser(add_help=False)
    parser.add_argument("--list", action="store_true", required=True)
    args = parser.parse_args()
    if args.list:
        public_ip = environ.get('PUBLIC_IP') or resource_attribute(tfstate(), "aws_instance", "demo", "public_ip")

        print(json.dumps(inventory(public_ip), indent=2))

def inventory(ip):
    return {
        "_meta" : {
            "hostvars" : {
                "demo": {
                    "ansible_host": ip
                }
            }
        },
        "all" : {
            "hosts" : [
                 "demo"
            ],
            "children": []
        }
   }

def tfstate():
    return json.loads(subprocess.check_output(["terraform", "state", "pull"]).decode())

def resource_attribute(tfstate, type, name, attr):
    resource = [x for x in tfstate["resources"] if x["type"] == type and x["name"] == name][0]
    return resource["instances"][0]["attributes"][attr]

if __name__ == '__main__':
    main()

The dynamic inventory script requires you specify the --list argument, and emits minimal information for the host demo. It returns _meta information so it does not need to support the --host argument.

When run by Terraform provisioning the state does not contain details of the EC2 instance, so the public IP has to be passed in the PUBLIC_IP variable.

When you run ansible-playbook site.yml to perform subsequent updates, it uses the Terraform state to find the public IP, and uses that.

If the public IP has changed (for example, after stopping/starting instance) you need to run terraform refresh first.

Variables

The variables define the AWS region to launch the EC2 instance in, and the AMI ID of the Debian Stretch AMI to launch.

Further Thoughts

You can use this approach with Remote State without modification.

As the SSH key used for deployment is part of the Terraform state, which I have stored in S3, my playbook includes modifying authorized_keys to lock the IP permitted for the deployment key.