DevOps.dev

Devops.dev is a community of DevOps enthusiasts sharing insight, stories, and the latest…

Follow publication

Interoperability for accuracy and repeatability- A Terraform and Ansible Love Story

--

Ansible And Terraform

Premise

As part of the day in the life of a DevOps engineer, we always look for ways to ease our day-to-day and try and live by the ethos “If I have to do it more than twice, I am automating it”. But trying to use the best tool for the job, might make this a bit difficult. Terraform is spectacular at stateful provisioning of infrastructure, Ansible is tried and tested for configuration management, but maybe no longer the best in class; Saltstack, Chef and Puppet might have something to say about that. But Ansible and Terraform don’t easily play together other than through JSON outputs.

Yes, we appreciate that you can use Terraform within Ansible and vice versa. And yes, we know you can export Terraform’s output and use it as extra-envs in Ansible but who wants to manually do this each and every time you need to run ansible scripts against multiple environments? So why not just pull in the remote state and use the values already stored there, well….let’s try it.

Dive-in

Pulling data from Terraform’s remote state is pretty much straightforward as the structure is common across backend states and stored as JSON. All that is needed is to authenticate, parse the data and load it into memory to be ingested and used by the current Ansible execution. Authentication to the various state backends, in this case, S3 and Azure blob storage, can be done in a variety of ways ranging from direct credentials within the Ansible scripts to global authentication performed using AWS and Azure CLI respectively.

Let us dive in, all code here will be relative to your Ansible scripts’ current working directory(CWD).

Apply the Terraform config and output and store some generated values in a remote state(S3 or Azure Blob Storage).

Let’s add some dependencies; we need boto3 for everything AWS and we use these specific Azure modules to authenticate and interact with the storage API.

pip3 install ansible boto3 azure-storage-blob azure-identity azure-mgmt-storage -U

Let’s pull in and parse the remote state in S3 or Azure Blob Storage as an Ansible action. This authenticates to the cloud provider, downloads the state and parses the output stored. You may notice a lot of exception handling and branching, this is to account for a variety of edge cases and reduces the complexity when consuming the module as an end user.

cwd: ./plugins/actions/load_terraform_vars.py

#!/usr/bin/python

# Copyright: (c) 2022, Ardoq <devops@ardoq.com>
# Make coding more python3-ish, this is required for contributions to Ansible
from __future__ import (absolute_import, division, print_function)

__metaclass__ = type

import json
import traceback

from ansible.errors import AnsibleError
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_native
from ansible.plugins.action import ActionBase

try:
import boto3
import botocore

HAS_BOTO = True
except ImportError:
BOTO_IMP_ERR = traceback.format_exc()
HAS_BOTO = False

try:
from azure.storage.blob import BlobClient
from azure.identity import AzureCliCredential
from azure.mgmt.storage import StorageManagementClient
HAS_AZURE = True
except ImportError:
AZURE_IMP_ERR = traceback.format_exc()
HAS_AZURE = False

def get_env(params) -> str:
if params.get('object') is not None and params.get('s3_bucket') is not None \\
and params.get('aws_region') is not None:
return 'aws'
if params.get('object') is not None and params.get('storage_account_name') is not None \\
and params.get('container_name') is not None:
return 'azure'
raise AnsibleError('Internal error: unknown cloud env')

def format_data(data) -> dict:
result = {}
outputs = json.loads(data)['outputs']
for key, value in outputs.items():
result[key] = value
return result

def fetch_from_s3(aws_profile, aws_region, bucket, terraform_object) -> dict:
try:
session = boto3.session.Session(profile_name=aws_profile)
s3 = session.client("s3", region_name=aws_region)
obj = s3.get_object(Bucket=bucket, Key=terraform_object)
raw_data = obj["Body"].read().decode('utf-8')
return raw_data
except ValueError as e:
raise AnsibleError("Couldn't connect to AWS: %s" % to_native(e))
except (botocore.exceptions.ProfileNotFound, botocore.exceptions.PartialCredentialsError,
botocore.exceptions.NoCredentialsError, botocore.exceptions.ConfigParseError,
botocore.exceptions.ClientError) as e:
raise AnsibleError(to_native(e))

def get_azure_storage_access_keys(credentials, subscription_id, resource_group, storage_account_name):
try:
storage_client = StorageManagementClient(credentials, subscription_id)
storage_keys = storage_client.storage_accounts.list_keys(resource_group, storage_account_name)
storage_keys = {v.key_name: v.value for v in storage_keys.keys}
return storage_keys['key1']
except Exception as e:
raise AnsibleError(to_native(e))

def fetch_from_azure_blob_storage(subscription_id, resource_group, storage_account_name, container_name, terraform_object):
try:
credentials = AzureCliCredential()
blob_client = BlobClient.from_blob_url(
blob_url=f"{storage_account_name}.blob.core.windows.net/{container_name}/{terraform_object}",
credential=get_azure_storage_access_keys(credentials, subscription_id, resource_group,
storage_account_name))
return blob_client.download_blob().readall()
except Exception as e:
raise AnsibleError(to_native(e))

class ActionModule(ActionBase):
TRANSFERS_FILES = False
_VALID_ARGS = frozenset(
('object',
's3_bucket', 'aws_region', 'aws_profile',
'storage_account_name', 'container_name', "subscription_id", "resource_group"))

def run(self, tmp=None, task_vars=None):
self._supports_check_mode = True
result = super(ActionModule, self).run(tmp, task_vars)
del tmp
if not HAS_BOTO:
raise AnsibleError(missing_required_lib('boto3'))
if not HAS_AZURE:
raise AnsibleError(missing_required_lib('azure-storage-blob'))

cloud_env = get_env(self._task.args)
terraform_object = self._task.args.get("object")
if cloud_env == "aws":
aws_profile = self._task.args.get("aws_profile") or "default"
aws_region = self._task.args.get("aws_region")
s3_bucket = self._task.args.get("s3_bucket")

try:
fetched = fetch_from_s3(aws_profile, aws_region, s3_bucket, terraform_object)
result['ansible_facts'] = format_data(fetched)
except Exception as e:
raise AnsibleError(e)

elif cloud_env == "azure":
storage_account_name = self._task.args.get("storage_account_name")
container_name = self._task.args.get("container_name")
subscription_id = self._task.args.get("subscription_id")
resource_group = self._task.args.get("resource_group")
try:
fetched = fetch_from_azure_blob_storage(subscription_id, resource_group, storage_account_name,
container_name, terraform_object)
result['ansible_facts'] = format_data(fetched)
except Exception as e:
raise AnsibleError(e)

else:
raise AnsibleError("Unknown cloud env")
return result

Let’s define the module’s options, we define what we can pass to our module when we call it from the tasks or a role. We add some defaults and some required documentation for it to be considered a “complete” Ansible module. Please note the file name of the module and plugin are the same, this is intentional and part of Ansible’s suggested pattern when developing plugins. It also defines how our plugin shall be named.

cwd: ./plugins/modules/load_terraform_vars.py

#!/usr/bin/python

# Copyright: (c) 2022, Ardoq <devops@ardoq.com>
# GNU General Public License v3.0+ (see COPYING or <https://www.gnu.org/licenses/gpl-3.0.txt>)
from __future__ import (absolute_import, division, print_function)

__metaclass__ = type

DOCUMENTATION = r'''
---
module: load_terraform_vars

short_description: Load vars/output from remote terraform backends

version_added: "0.0.1"

description: Load vars/output from remote terraform backends; including, local filesystem, S3 and Azure Blob Storage
options:
object:
description:
- Name of the s3 object/file where Terraform state is stored.
required: true
type: str
s3_bucket:
description:
- Name of the s3 bucket where Terraform state is stored.
type: str
default: ""
aws_profile:
description:
- Name of the aws profile to be used.
default: "default"
aws_access_key:
description:
- AWS access key to be used for bucket access.
- If declared aws_profile option is ignored and aws_secret_access_key option is required.
default: ""
type: str
aws_secret_access_key:
description:
- AWS secret access key to be used for bucket access.
- If declared aws_profile option is ignored and aws_access_key option is required.
default: ""
type: str
aws_region:
description:
- ID of AWS region to connect to s3 bucket from.
default: ""
type: str
storage_account_name:
description:
- Azure storage account name
default: ""
type: str
container_name:
description:
- Azure blob storage container name
default: ""
type: str
resource_group_name:
description:
- Azure resource group name
default: ""
type: str
subscription_id:
description:
- Azure subscription id
default: ""
type: str

# Specify this value according to your collection
# in format of namespace.collection.doc_fragment_name
extends_documentation_fragment:
- ardoq.vars.vars

author:
- Ardoq Devops
'''


EXAMPLES = r'''
# Load from the local file system
- name: Test with a message
ardoq.vars.load_terraform_vars:
object: "storage/terraform.tfstate"

# Load from S3
- name: Test with a message and changed output
ardoq.vars.load_terraform_vars:
s3_bucket: test_bucket
aws_region: "us-east-1"
object: "terraform.tfstate"

# Load from Azure Blob Storage
- name: Test with a message and changed output
ardoq.vars.load_terraform_vars:
storage_account_name: test
container_name: "test"
resource_group_name "test"
object: "terraform.tfstate"

'''


RETURN = r'''
# These are examples of possible return values, and in general should use other names for return values.
ansible_facts:
description: Variables that were included and their values.
returned: success
type: dict
sample: {'variable': 'value'}
'''

Let’s connect them together in the Ansible configuration file; module paths are defined under the library section, while action plugins are defined under the action_plugins section. We append the paths of our module and plugin to their respective sections.

cwd: ./ansible.cfg

[defaults]
library = ~/.ansible/plugins/modules:/usr/share/ansible/plugins/modules:./plugins/modules
action_plugins = ~/.ansible/plugins/action:/usr/share/ansible/plugins/action:./plugins/actions

Finally, let’s use the new module in our tasks, we shall be calling it in our pre-tasks. It should be noted that we use the always tag to ensure it is executed in spite of any applied limits or exclusions on the execution,i.e. whether we run the script with tags or are running a single, named task. Given that we are running this as part of the execution, variables fetched from the remote state override any passed variables if they share the same name.

---
- hosts: all
gather_facts: True
become: yes
become_method: sudo
strategy: free

pre_tasks:
- name: Load variables into the aws namespace
load_terraform_vars:
object: terraform.tfstate
s3_bucket: "org-test-terraform-state"
aws_region: "us-east-1"
tags:
- always
when: ansible_system_vendor=="Amazon EC2"
- name: Load variables into the azure namespace
load_terraform_vars:
object: "test.terraform.tfstate"
subscription_id: "{a_subscription_id}"
resource_group: "test-terraform-backend"
storage_account_name: "org_test_terraform_backend"
container_name: "terraform-backend"
tags:
- always
when: ansible_system_vendor=="Microsoft Corporation"

But it wasn’t all rosy, in macOS, there is an issue with forking in python due to a safety feature in Mac hence we need to fix this by disabling fork safety.

cwd: ./.env

export NO_PROXY=’*’
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
source ./env

After all that, we should be able to use our terraform output in Ansible scripts and templates.

TL;DR

There’s more than one way to Rome, but in the end, we go for the simplest, safest and most accurate. We do not want to cross-contaminate environments with values from other environments. With proper templating and group variables, we are able to properly ensure we always fetch the right values for the job from the right backend.

References and Shoutouts

Vars plugins - Ansible Documentation

Developing modules - Ansible Documentation

Developing plugins - Ansible Documentation

Why Ansible and Python fork break on macOS High Sierra+ and how to solve

#StayLazy

--

--

Published in DevOps.dev

Devops.dev is a community of DevOps enthusiasts sharing insight, stories, and the latest development in the field.

Written by Ian Muge

If I have to do it more than twice, I am automating it. #StayLazy

No responses yet

Write a response