Yes, your eyes are not deceiving you π Usually, when the words Ansible and Terraform appear in the same sentence is for the opposite scenario. Many articles out there provide examples of using Terraform to deploy infrastructure followed by running Ansible to configure it using the local-exec provisioner. This is a good thing because, if you need some post-provisioning configuration, it is better to rely on another declarative and idempotent tool like Ansible, rather than invoking shell scripts.
But in this post we are not going to do any of that. What we are going to do is the other way around. We are going to have an Ansible playbook that runs your Terraform plan.
This article is a follow up from a series of posts to introduce infrastructure admins and engineers to Terraform and to explain how to use it to manage on-prem physical infrastructure in a datacenter. You can find access the full series here.
Reasons to do it
But why? Well, if you have an physical infrastructure background and are running a traditional datacenter more than likely you have been playing with Ansible. Chances are you started with Ansible running in the command line and hard-coding your infrastructure’s credentials inside the playbook. Then you evolved into using an external credentials file and encrypting it using “ansible-vault”. Eventually you might have deployed Ansible AWX or even RedHat AAP. At this point, you are enjoying RBAC (who can run what playbook and on what infrastructure) and amazing (there is no other way to put it) credential management.
Then, you talk to your developers over coffee and they tell you about Terraform. But when you get familiar with it, you discover that unless you pay for a SaaS offering you are stuck in the “command-line with hard-coded credentials” scenario. Wouldn’t it be great if you could use AWX to run Terraform? You could enjoy RBAC and credential management and all the other goodness that it provides. This is possible thanks to Ansible’s Terraform module.
First steps
Let’s say we have a very basic Terraform project to create a storage group in Dell PowerMax. The “main.tf” shown below starts the PowerMax provider and then creates a storage group “terraform_sg”. I don’t think it is strictly necessary, but if you are interested in learning more about provisioning storage in Dell PowerMax you can check out this previous post. For simplicity we haven’t created or referenced any variables. All the information is contained into a single file, the “main.tf” shown below.
terraform {
required_providers {
powermax = {
source = "dell/powermax"
}
}
}
provider "powermax" {
username = "smc"
password = "TFp2ssw0rd"
endpoint = "https://10.1.2.3:8443"
serial_number = "000123456789"
pmax_version = 100
insecure = true
}
resource "powermax_storagegroup" "tf_sg" {
name = "terraform_sg"
srp_id = "SRP_1"
}
In the same machine we have both Terraform and Ansible installed. In order to run the above Terraform plan with Ansible, we need at a minimum a playbook that looks like follows. It needs to include two mandatory parameters: “project_path” and “state”. The first parameter specifies the directory that contains the plan, ie the “main.tf” and typically any other files like “variables.tf”. My Ansible playbook is called “runtf.yml”.
---
- hosts: localhost
gather_facts: no
vars:
project_dir: "/root/powermax" # folder that contains main.tf, ...
tasks:
- name: Run terraform
community.general.terraform:
project_path: '{{ project_dir }}'
state: present
register: output
- debug:
var: output
As you can see I have registered the output of the terraform task and then created a second task to print it out on the terminal so that we can get more details. All is left now is to run it using the “ansible-playbook” command
root@alb-terraform:~# ansible-playbook runtf.yml
PLAY [localhost] ***************************************************************************************
TASK [Run terraform] ***************************************************************************************
changed: [localhost]
TASK [debug] ***************************************************************************************
ok: [localhost] => {
"output": {
"changed": true,
"command": "/usr/bin/terraform apply -no-color -input=false -auto-approve -lock=true /tmp/tmpjaojemdl.tfplan",
"failed": false,
"outputs": {},
"state": "present",
"stderr": "",
"stderr_lines": [],
"stdout": "powermax_storagegroup.tf_sg: Creating...\npowermax_storagegroup.tf_sg: Creation complete after 0s [id=terraform_sg]\n\nApply complete! Resources: 1 added, 0 changed, 0 destroyed.\n",
"stdout_lines": [
"powermax_storagegroup.tf_sg: Creating...",
"powermax_storagegroup.tf_sg: Creation complete after 0s [id=terraform_sg]",
"",
"Apply complete! Resources: 1 added, 0 changed, 0 destroyed."
],
"workspace": "default"
}
}
PLAY RECAP ***************************************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 ignored=0
Ansible run Terraform, and Terraform managed to make the changes so as expected, Ansible reports the task as “changed”. In the “debug” task, notice the “command” parameter. This is how Ansible is invoking Terraform. This invocation includes the “-auto-approve” flag so that Terraform doesn’t look for confirmation. The “stdout” and stdout_lines” parameters show the output that has been captured from Terraform about the creation of the different resources .
A very important feature of both Ansible and Terraform is idempotency, ie the ability to run the same code repeatedly without making any damage. Let’s run it again and observe what happens.
root@alb-terraform:~# ansible-playbook runtf.yml
PLAY [localhost] ***************************************************************************************
TASK [Run terraform] ***************************************************************************************
ok: [localhost]
TASK [debug] ***************************************************************************************
ok: [localhost] => {
"output": {
"changed": false,
"command": "/usr/bin/terraform apply -no-color -input=false -auto-approve -lock=true /tmp/tmp718kkwbo.tfplan",
"failed": false,
"outputs": {},
"state": "present",
"stderr": "",
"stderr_lines": [],
"stdout": "powermax_storagegroup.tf_sg: Refreshing state... [id=terraform_sg]\n\nNo changes. Your infrastructure matches the configuration.\n\nTerraform has compared your real infrastructure against your configuration\nand found no differences, so no changes are needed.\n",
"stdout_lines": [
"powermax_storagegroup.tf_sg: Refreshing state... [id=terraform_sg]",
"",
"No changes. Your infrastructure matches the configuration.",
"",
"Terraform has compared your real infrastructure against your configuration",
"and found no differences, so no changes are needed."
],
"workspace": "default"
}
}
PLAY RECAP ***************************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 ignored=0
As expected, Ansible is reporting “ok”, which means the current state matched the desired state and therefore no changes were made. It has also captured the relevant from Terraform stating the same thing.
Using variables
This is a good start but ideally we would like to have the ability to make the plan more flexible by using variables for information such as the storage group name in this case. We can modify the “main.tf” as follows. Notice how the “default” value of the variable is commented out. It doesn’t matter if it is commented or not because when we invoke Terraform with external variables we overwrite the default value defined in the variable blocks.
terraform {
required_providers {
powermax = {
source = "dell/powermax"
}
}
}
provider "powermax" {
username = "smc"
password = var.password
endpoint = "https://10.1.2.3:8443"
serial_number = "000123456789"
pmax_version = 100
insecure = true
}
variable "sg_name" {
description = "Name of volume to create"
type = string
#default = "terraform_sg"
}
variable "password" {
type = string
description = "Stores the password of Unisphere."
#default = ""
}
resource "powermax_storagegroup" "tf_sg" {
name = var.sg_name
srp_id = "SRP_1"
}
Additionally, notice how in the “provider” block we have also referenced another variable for the “password”. For security reasons we might want to have, sensitive information like that, stored in a separate file and protect it. In this case we haven’t even declared a default value because we can leverage ansible-vault or AWX to encrypt it and to pass it to Terraform at run time.
Now, the corresponding Ansible playbook needs to use the “variables” parameter. This parameter essentially converts each of the variables into a “-var” flag when invoking Terraform.
---
- hosts: localhost
gather_facts: no
vars:
project_dir: "/root/powerstore" # folder that contains main.tf, ...
tasks:
- name: Run terraform
community.general.terraform:
project_path: '{{ project_dir }}'
state: present
variables:
sg_name: "{{ vol_name }}"
password: "{{ password }}"
register: output
- debug:
var: output
Now, at runtime we are going to select a different storage group name. The storage group is created as expected and the relevant Terraform output is captured.
root:~# ansible-playbook runtf.yml --extra-vars "sg_name=tf-sg2 password=TFp2ssw0rd"
PLAY [localhost] ***************************************************************************************
TASK [Run terraform] ***************************************************************************************
changed: [localhost]
TASK [debug] ***************************************************************************************
ok: [localhost] => {
"output": {
"changed": true,
"command": "/usr/bin/terraform apply -no-color -input=false -auto-approve -lock=true /tmp/tmpimdwzdu1.tfplan",
"failed": false,
"outputs": {},
"state": "present",
"stderr": "",
"stderr_lines": [],
"stdout": "powermax_storagegroup.tf_sg: Creating...\npowermax_storagegroup.tf_sg: Creation complete after 5s [id=vol1]\n\nApply complete! Resources: 1 added, 0 changed, 0 destroyed.\n",
"stdout_lines": [
"powermax_storagegroup.tf_sg: Creating...",
"powermax_storagegroup.tf_sg: Creation complete after 5s [id=vol1]",
"",
"Apply complete! Resources: 1 added, 0 changed, 0 destroyed."
],
"workspace": "default"
}
}
PLAY RECAP ***************************************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 ignored=0
Terraform plan
We went straight to “terraform apply” but most Terraform workflows also include running the plan command first. This is done with Ansible by running the playbook in “check mode”. Check mode invokes “terraform plan” for us.
root:~# ansible-playbook runtf.yml --check --extra-vars "sg_name=tf-sg3 password=TFpass"
PLAY [localhost] ***************************************************************************************
TASK [Run terraform] ***************************************************************************************
ok: [localhost]
TASK [debug] ***************************************************************************************
ok: [localhost] => {
"output": {
"changed": false,
"command": "/usr/bin/terraform apply -no-color -input=false -auto-approve -lock=true /tmp/tmp6f2o83k5.tfplan",
"failed": false,
"outputs": {},
"state": "present",
"stderr": "",
"stderr_lines": [],
"stdout": "powermax_storagegroup.tf_sg: Refreshing state... [id=tf-sg2]\n\nTerraform used the selected providers to generate the following execution\nplan. Resource actions are indicated with the following symbols:\n ~ update in-place\n\nTerraform will perform the following actions:\n\n # powermax_storagegroup.tf_sg will be updated in-place\n ~ resource \"powermax_storagegroup\" \"tf_sg\" {\n ~ cap_gb = 0 -> (known after apply)\n + child_storage_group = (known after apply)\n ~ compression = true -> (known after apply)\n + compression_ratio = (known after apply)\n ~ compression_ratio_to_one = 0 -> (known after apply)\n + device_emulation = (known after apply)\n ~ id = \"tf-sg2\" -> (known after apply)\n + maskingview = (known after apply)\n ~ name = \"tf-sg2\" -> \"tf-sg3\"\n ~ num_of_child_sgs = 0 -> (known after apply)\n ~ num_of_masking_views = 0 -> (known after apply)\n ~ num_of_parent_sgs = 0 -> (known after apply)\n + num_of_snapshot_policies = (known after apply)\n ~ num_of_snapshots = 0 -> (known after apply)\n ~ num_of_vols = 0 -> (known after apply)\n + parent_storage_group = (known after apply)\n + service_level = (known after apply)\n ~ slo = \"NONE\" -> (known after apply)\n ~ slo_compliance = \"NONE\" -> (known after apply)\n + snapshot_policies = (known after apply)\n + tags = (known after apply)\n ~ type = \"Standalone\" -> (known after apply)\n ~ unprotected = true -> (known after apply)\n ~ unreducible_data_gb = 0 -> (known after apply)\n + uuid = (known after apply)\n ~ volume_ids = [] -> (known after apply)\n + vp_saved_percent = (known after apply)\n + workload = (known after apply)\n # (2 unchanged attributes hidden)\n }\n\nPlan: 0 to add, 1 to change, 0 to destroy.\n\nβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ\n\nSaved the plan to: /tmp/tmp6f2o83k5.tfplan\n\nTo perform exactly these actions, run the following command to apply:\n terraform apply \"/tmp/tmp6f2o83k5.tfplan\"\n",
"stdout_lines": [
"powermax_storagegroup.tf_sg: Refreshing state... [id=tf-sg2]",
"",
"Terraform used the selected providers to generate the following execution",
"plan. Resource actions are indicated with the following symbols:",
" ~ update in-place",
"",
"Terraform will perform the following actions:",
"",
" # powermax_storagegroup.tf_sg will be updated in-place",
" ~ resource \"powermax_storagegroup\" \"tf_sg\" {",
" ~ cap_gb = 0 -> (known after apply)",
" + child_storage_group = (known after apply)",
" ~ compression = true -> (known after apply)",
" + compression_ratio = (known after apply)",
" ~ compression_ratio_to_one = 0 -> (known after apply)",
" + device_emulation = (known after apply)",
" ~ id = \"tf-sg2\" -> (known after apply)",
" + maskingview = (known after apply)",
" ~ name = \"tf-sg2\" -> \"tf-sg3\"",
" ~ num_of_child_sgs = 0 -> (known after apply)",
" ~ num_of_masking_views = 0 -> (known after apply)",
" ~ num_of_parent_sgs = 0 -> (known after apply)",
" + num_of_snapshot_policies = (known after apply)",
" ~ num_of_snapshots = 0 -> (known after apply)",
" ~ num_of_vols = 0 -> (known after apply)",
" + parent_storage_group = (known after apply)",
" + service_level = (known after apply)",
" ~ slo = \"NONE\" -> (known after apply)",
" ~ slo_compliance = \"NONE\" -> (known after apply)",
" + snapshot_policies = (known after apply)",
" + tags = (known after apply)",
" ~ type = \"Standalone\" -> (known after apply)",
" ~ unprotected = true -> (known after apply)",
" ~ unreducible_data_gb = 0 -> (known after apply)",
" + uuid = (known after apply)",
" ~ volume_ids = [] -> (known after apply)",
" + vp_saved_percent = (known after apply)",
" + workload = (known after apply)",
" # (2 unchanged attributes hidden)",
" }",
"",
"Plan: 0 to add, 1 to change, 0 to destroy.",
"",
"βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ",
"",
"Saved the plan to: /tmp/tmp6f2o83k5.tfplan",
"",
"To perform exactly these actions, run the following command to apply:",
" terraform apply \"/tmp/tmp6f2o83k5.tfplan\""
],
"workspace": "default"
}
}
PLAY RECAP ***************************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 ignored=0
No changes are made and the Ansible module has captured the details of the changes that will be made if the plan is applied.
Terraform destroy
Finally we need to learn how to use “terraform destroy”. You might have guessed it by now. The native way of destroying something in Ansible is by setting the state to “absent”.
---
- hosts: localhost
gather_facts: no
vars:
project_dir: "/root/powermax" # dir that contains main.tf, ...
tasks:
- name: Run terraform
community.general.terraform:
project_path: '{{ project_dir }}'
state: absent
variables:
sg_name: "{{ sg_name }}"
password: "{{ password }}"
register: output
- debug:
var: output
Let’s run it and see what it does.
root:~# ansible-playbook runtf.yml --extra-vars "sg_name=tf-sg2 password=TFp2ssw0rd"
PLAY [localhost] ***************************************************************************************
TASK [Run terraform] ***************************************************************************************
changed: [localhost]
TASK [debug] ***************************************************************************************
ok: [localhost] => {
"output": {
"changed": true,
"command": "/usr/bin/terraform destroy -no-color -auto-approve -lock=true -var sg_name=tf-sg2 -var password=smc",
"failed": false,
"outputs": {},
"state": "absent",
"stderr": "",
"stderr_lines": [],
"stdout": "powermax_storagegroup.tf_sg: Refreshing state... [id=tf-sg2]\n\nTerraform used the selected providers to generate the following execution\nplan. Resource actions are indicated with the following symbols:\n - destroy\n\nTerraform will perform the following actions:\n\n # powermax_storagegroup.tf_sg will be destroyed\n - resource \"powermax_storagegroup\" \"tf_sg\" {\n - cap_gb = 0 -> null\n - compression = true -> null\n - compression_ratio_to_one = 0 -> null\n - host_io_limit = {} -> null\n - id = \"tf-sg2\" -> null\n - name = \"tf-sg2\" -> null\n - num_of_child_sgs = 0 -> null\n - num_of_masking_views = 0 -> null\n - num_of_parent_sgs = 0 -> null\n - num_of_snapshots = 0 -> null\n - num_of_vols = 0 -> null\n - slo = \"NONE\" -> null\n - slo_compliance = \"NONE\" -> null\n - srp_id = \"SRP_1\" -> null\n - type = \"Standalone\" -> null\n - unprotected = true -> null\n - unreducible_data_gb = 0 -> null\n - volume_ids = [] -> null\n }\n\nPlan: 0 to add, 0 to change, 1 to destroy.\npowermax_storagegroup.tf_sg: Destroying... [id=tf-sg2]\npowermax_storagegroup.tf_sg: Destruction complete after 0s\n\nDestroy complete! Resources: 1 destroyed.\n",
"stdout_lines": [
"powermax_storagegroup.tf_sg: Refreshing state... [id=tf-sg2]",
"",
"Terraform used the selected providers to generate the following execution",
"plan. Resource actions are indicated with the following symbols:",
" - destroy",
"",
"Terraform will perform the following actions:",
"",
" # powermax_storagegroup.tf_sg will be destroyed",
" - resource \"powermax_storagegroup\" \"tf_sg\" {",
" - cap_gb = 0 -> null",
" - compression = true -> null",
" - compression_ratio_to_one = 0 -> null",
" - host_io_limit = {} -> null",
" - id = \"tf-sg2\" -> null",
" - name = \"tf-sg2\" -> null",
" - num_of_child_sgs = 0 -> null",
" - num_of_masking_views = 0 -> null",
" - num_of_parent_sgs = 0 -> null",
" - num_of_snapshots = 0 -> null",
" - num_of_vols = 0 -> null",
" - slo = \"NONE\" -> null",
" - slo_compliance = \"NONE\" -> null",
" - srp_id = \"SRP_1\" -> null",
" - type = \"Standalone\" -> null",
" - unprotected = true -> null",
" - unreducible_data_gb = 0 -> null",
" - volume_ids = [] -> null",
" }",
"",
"Plan: 0 to add, 0 to change, 1 to destroy.",
"powermax_storagegroup.tf_sg: Destroying... [id=tf-sg2]",
"powermax_storagegroup.tf_sg: Destruction complete after 0s",
"",
"Destroy complete! Resources: 1 destroyed."
],
"workspace": "default"
}
}
PLAY RECAP ***************************************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 ignored=0
The storage group resource has been destroyed and that shows in Ansible as a task with a status of “changed”. The “stdout” and “stdout_lines” parameters provide a record of what was destroyed.
In summary, in this article we have seen how to use the terraform Ansible module to plan, apply and destroy Terraform projects. In doing that, organizations can leverage a tool like AWX or RedHat AAP to provide RBAC, strong credential management and a powerful upstream REST API and integrate with other tools like ITSM etc. AWX and AAP can also became a polyglot automation platform capable of running the two most popular automation tools out there to satisfy both traditional infrastructure teams and developers.
Categories: DellEMC
