Using Terraform and Ansible Together

This is a follow-up to Using the Morpheus Terraform Provider and Spec Templates, where I was deploying Dynamic DNS (DDNS) using ddns-route53. In the thread we used Spec Templates and the Morpheus Terraform Provider to deploy infrastructure needed to support DDNS.

I mentioned at the end, that there is an Ansible task I created to completely configure the virtual machine that is deployed. Using Morpheus to provide this functionality makes it much easier to link all of the technologies together. Additionally, we’ll be using the Command Bus as part of our integration, which will send the Ansible commands to the VM securely from Morpheus to the agent, without the need of opening any inbound ports. This is effectively giving Ansible an agent, like some of configuration management systems, and also eliminates the need to maintain a service account on each image to authenticate with over the network.

Lets jump into it! Note that Ubuntu 22.04 was used in this deployment.

The Code

In addition to the previous Terraform files, we have the following new file to mention:

  • setupAnsible.tf

As well, this is the Ansible playbook that will be used:

  • config.yml

Finally, some config file templates that will be copied to the VM and used to setup the service, using Ansible:

  • ddns_config.yml
  • ddns-route53.service

setupAnsible.tf:

resource "morpheus_ansible_playbook_task" "ddns_playbook" {
  name                = "DDNS-Config"
  code                = "ddns-config"
  ansible_repo_id     = 7
  git_ref             = "dev"
  playbook            = "ddns/ansible/config.yml"
  execute_target      = "resource"
  retryable           = true
  retry_count         = 1
}

resource "morpheus_provisioning_workflow" "ddns_workflow" {
  name        = "DDNS-Config"
  description = "DDNS provisioning workflow"
  task {
    task_id    = morpheus_ansible_playbook_task.ddns_playbook.id
    task_phase = "provision"
  }
}

Looking at the setupAnsible.tf above, we are creating two items:

  1. An Ansible Playbook Task
  2. A Provisioning Workflow, which will contain the task created in the preceding step

I already have an Ansible integration configured in Morpheus, so I use the static repo ID for the integration here, but you could add the integration as well through the provisioning. We specify the config.yml playbook path to configure the task to run this playbook and the execute target as resource to ensure the playbook is ran against the new target system, not the Morpheus appliance.

We place the task in the Provisioning phase of the workflow, so if it fails the entire deployment will fail. Alternatively, you could add the task to the Post Provisioning phase, which would allow the task to fail and not mark the entire deployment as failed.

Don’t forget to add the setupAnsible.tf file to the App Blueprint as a Spec Template, like the others mentioned in the previous post!

config.yml:

---

- name: DDNS Config 
  gather_facts: false
  hosts: all
  vars:
    accessKeyId: "{{ lookup('cypher','secret=secret/ddns').split('|')[0] }}"
    secretAccessKey: "{{ lookup('cypher','secret=secret/ddns').split('|')[1] }}"
  tasks:
    - name: "Verify that required string variables are defined"
      assert:
        that: 
          - "{{ ahs_var }} is defined"
          - "{{ ahs_var }} | length > 0"
          - "{{ ahs_var }} != None"
        fail_msg: "{{ ahs_var }} needs to be set for the role to work"
        success_msg: "Required variable {{ ahs_var }} is defined"
      loop_control:
        loop_var: ahs_var
      with_items:
        - accessKeyId
        - secretAccessKey
    - name: Download ddns
      ansible.builtin.get_url:
        url: https://github.com/crazy-max/ddns-route53/releases/download/v2.8.0/ddns-route53_2.8.0_linux_amd64.tar.gz
        dest: /tmp/ddns.tar.gz
        mode: '0755'
    - name: Create a directory if it does not exist
      ansible.builtin.file:
        path: /tmp/ddns
        state: directory
        mode: '0755'
    - name: Extract ddns
      ansible.builtin.unarchive:
        src: /tmp/ddns.tar.gz
        dest: /tmp/ddns
        remote_src: yes
    - name: Ensure group "ddns-route53" exists
      ansible.builtin.group:
        name: ddns-route53
        state: present
    - name: Add the user 'ddns-route53' and a group of 'ddns-route53'
      ansible.builtin.user:
        name: ddns-route53
        comment: Dynamic DNS
        group: ddns-route53
        shell: /bin/false
        home: /bin/null
    - name: Create a directory if it does not exist
      ansible.builtin.file:
        path: /etc/ddns-route53/
        state: directory
        mode: '0755'
    - name: Create ddns config
      ansible.builtin.template:
        src: ./templates/ddns_config.yml
        dest: /etc/ddns-route53/ddns-route53.yml
        owner: ddns-route53
        group: ddns-route53
        mode: '0644'
    - name: Copy file with owner and permissions
      ansible.builtin.copy:
        src: /tmp/ddns/ddns-route53
        dest: /usr/local/bin/ddns-route53
        owner: ddns-route53
        group: ddns-route53
        mode: '0744'
        remote_src: yes
    - name: Template a file to /etc/ddns-route53/ddns-route53.yml
      ansible.builtin.template:
        src: ./templates/ddns-route53.service
        dest: /etc/systemd/system/ddns-route53.service
        owner: ddns-route53
        group: ddns-route53
        mode: '0644'
    - name: Start service ddns-route53, if not started, and enable
      ansible.builtin.service:
        name: ddns-route53
        state: started
        enabled: yes

There are a few items to note in the Ansible playbook config.yml above. We are looking up Cypher values, which will be used in the playbook. Using Cypher in Ansible is a different format than when we used them in Terraform and other task types, which more info can be found here.

Many of these steps are basically following the documentation for ddns-route53 documentation, at a high level we are:

  1. Downloading and unpacking the software
  2. Create the user/group needed
  3. Copying the configuration file template (ddns_config.yml) for the service to be able to connect AWS
  4. Copying the configuration file (ddns-route53.service) for the service to run automatically

ddns_config.yml and ddns-route53.service are shown below for visibility.

ddns_config.yml (some values modified, which could be dynamic):

credentials:
  accessKeyID: "{{ accessKeyId }}"
  secretAccessKey: "{{ secretAccessKey }}"

route53:
  hostedZoneID: "AAAAAAABBBBBBBCCCCCCC"
  recordsSet:
    - name: "morpheus.example.com."
      type: "A"
      ttl: 300

ddns-route53.service:

[Unit]
Description=ddns-route53
Documentation=https://crazymax.dev/ddns-route53/
After=syslog.target
After=network.target

[Service]
RestartSec=2s
Type=simple
User=ddns-route53
Group=ddns-route53
ExecStart=/usr/local/bin/ddns-route53 --config /etc/ddns-route53/ddns-route53.yml
Restart=always
Environment=SCHEDULE="*/30 * * * *"

[Install]
WantedBy=multi-user.target

That’s it! Once deployed, all of your infrastructure is built (mostly from the last topic) and the additional tasks/workflows to support the configuration of the DDNS service. You would see something similar to this in the history of the instance deployed, confirming the VM has been configure to spec!

The service will begin to update your hosted zone record in Route 53 with your public IP address.

3 Likes

Awesome write up @kgawronski thanks for sharing your examples with the community :slight_smile: