Skip to content

Manage Exoscale DNS with Ansible

Introduction

In this post I will show you how to use Ansible to configure DNS records on Exoscale, a Swiss public cloud provider. Exoscale offers a DNS service at an affordable price, with geo replication, anycast and an API.

The two modules we are going to use, exo_dns_domain and exo_dns_record, are ones I wrote, and they have shipped with Ansible since version 2.2.0.

This post was written in 2017, when these modules were part of Ansible itself. With the collections split in Ansible 2.10 they moved out of ansible-core: the Exoscale DNS modules into the ngine_io.exoscale collection and cs_instance into ngine_io.cloudstack. Install the collection with ansible-galaxy collection install ngine_io.exoscale and refer to the modules by their fully qualified names. Exoscale has since introduced a v2 API, and the template and service offering names used below no longer exist. The structure of the playbook is unchanged.

Authentication

Exoscale’s compute API — powered by CloudStack — and the DNS API share the same API key and secret. That is why the DNS modules support the same authentication methods as the Ansible CloudStack modules.

If you already have a cloudstack.ini file, the DNS modules will pick up the same configuration. If not, see the credentials section of the Ansible CloudStack guide for the available options.

Define the Inventory

Let’s start with a good old static inventory:

1# file: hosts
2[cloud]
3search-01.example.com
4search-02.example.com

The hosts are defined with their fully qualified domain names. That domain and those host records are what we are going to create.

Create the Playbook

Before we can create host records, the domain has to exist. So the very first action is to make sure it does. Our domain of choice is example.com, and one task is enough:

1---
2- name: cloud base setup
3  hosts: localhost
4  gather_facts: false
5  tasks:
6    - name: ensure domain exists
7      local_action:
8        module: exo_dns_domain
9        name: example.com

The play targets localhost, because this only has to run once — creating the domain is not a per-host operation. And since the modules talk to an HTTP API rather than to the managed machine, they run locally, which is what local_action expresses.

Setting up several domains is a matter of looping over a list:

 1---
 2- name: cloud base setup
 3  hosts: localhost
 4  gather_facts: false
 5  vars:
 6    domains:
 7      - example.com
 8      - example2.com
 9      - example3.com
10  tasks:
11    - name: ensure domains exist
12      local_action:
13        module: exo_dns_domain
14        name: "{{ item }}"
15      with_items: "{{ domains }}"

Note that with_items is a clause of the task, not of local_action — it has to be indented at the same level as the module call, or Ansible will pass it to the module as an argument. In current Ansible you would write loop instead.

A Records for all the Hosts

Let’s step away from DNS for a moment, because we need machines first.

Since we already have an Exoscale account, deploying the VMs takes no extra effort: the CloudStack module cs_instance does it.

 1---
 2- name: install VMs in the cloud
 3  hosts: cloud
 4  gather_facts: false
 5  tasks:
 6    - name: create cloud VMs
 7      local_action:
 8        module: cs_instance
 9        display_name: "{{ inventory_hostname }}"
10        template: Linux Debian 8 64-bit 10G Disk (2016-06-07-7afacb)
11        service_offering: Tiny
12        security_groups:
13          - default
14          - search
15        ssh_key: defaultkey
16      register: vm
This example assumes the security groups and the SSH key already exist. There are Ansible modules to manage those as well.

Running this play creates the VMs of the inventory group cloud, and each of them gets an IP address. But which one?

The API returns a good deal of data along with the created instance. In Ansible these are called return values and they are documented alongside the module — run ansible-doc cs_instance and scroll down to the RETURN VALUES section to see all of them.

The register clause at the bottom of the task stores those return values in a variable we named vm, which makes them available to the following tasks. Printing the address is then a one-liner:

1- name: print VM infos
2  debug:
3    msg: "VM {{ inventory_hostname }} has IP {{ vm.default_ip }}"

At this point we have everything we need to register an A record:

1- name: create record with VM host's IP
2  local_action:
3    module: exo_dns_record
4    domain: "example.com"
5    name: "{{ inventory_hostname_short }}"
6    content: "{{ vm.default_ip }}"
7  when: "vm.default_ip is defined"

The name is the host part of the record — search-01 — and the magic variable inventory_hostname_short gives us exactly that: everything in the inventory name up to the first dot.

Unless a record_type is given, exo_dns_record creates an A record, with a default TTL of 3600 seconds.

A Service Record for our Search Cluster

Our application is a distributed search engine, and we would like a service record for it. The name of choice for our next generation search engine is search.example.com, and we want DNS round robin for load distribution.

That means several A records with the same name and different addresses, or content.

If we simply reused the task above with a static name, we would end up in a race: every host would overwrite the record, and whichever the play handled last would win.

1- name: create search.example.com
2  local_action:
3    module: exo_dns_record
4    domain: "example.com"
5    name: "search"
6    content: "{{ vm.default_ip }}"
7  when: "vm.default_ip is defined"

Luckily the module has an argument for exactly this case, multiple:

1- name: create search.example.com
2  local_action:
3    module: exo_dns_record
4    domain: "example.com"
5    name: "search"
6    content: "{{ vm.default_ip }}"
7    multiple: true
8  when: "vm.default_ip is defined"

With multiple: true the module identifies an existing record by name and content, so each host adds its own address instead of replacing its neighbour’s.

Multiple records with the same name only make sense for address records, and multiple is restricted accordingly.

Run the Playbook

The complete playbook is ready for lift-off:

 1- name: ensure domain exists
 2  hosts: localhost
 3  gather_facts: false
 4  tasks:
 5    - name: ensure domain exists
 6      local_action:
 7        module: exo_dns_domain
 8        name: example.com
 9
10- name: install VMs in the cloud
11  hosts: cloud
12  gather_facts: false
13  tasks:
14    - name: create cloud VMs
15      local_action:
16        module: cs_instance
17        display_name: "{{ inventory_hostname }}"
18        template: Linux Debian 8 64-bit 10G Disk (2016-06-07-7afacb)
19        service_offering: Tiny
20        security_groups:
21          - default
22          - search
23        ssh_key: defaultkey
24      register: vm
25
26    - name: create search.example.com
27      local_action:
28        module: exo_dns_record
29        domain: "example.com"
30        name: "search"
31        content: "{{ vm.default_ip }}"
32        multiple: true
33      when: "vm.default_ip is defined"

We run it in diff mode to see what is going to change:

$ ansible-playbook cloud.yml --diff -i hosts

Summary

Above we automated DNS configuration with Ansible. We only created A records here, but exo_dns_record supports all the common record types.

Both modules also implement diff and check mode, so you can always see exactly what a run is going to change before it changes it — which is the property that makes DNS automation something you can trust.