Testing changes when contributing to Ansible

This is by no means a detailed post about testing Ansible.  The main reason I’m writing this down is because I know I’ll forget by a few months time.

Goal:

  • Create an Ansible plugin filter to calculate the distance between two coordinates (haversine)
  • Write and test the required unit tests (and integration?) for the code to be accepted into Ansible
  • Raise a PR to the main project

Background

I was writing an Ansible playbook to check the local traffic site for accidents.  The site returns a list of incidents state wide, which, is great, but I was only interested in a 10km radius.  Eg:

 .....
 with_items: “{{ traffic.json.features }}”

 when: item | distance(myLong,myLat, item.geometry.coordinates.1, item.geometry.coordinates.0)|int < 10

This looped over a variable with_items and then passed some coordinates through the a custom plugin filter called distance.  (Note: Distance was a custom plugin filter which I stored in the plugin_filters directory.  I figured I’d go a little further and try to submit to Ansible core.)

Fork

  • I’ve forked ansible/ansible from github into my own repo.
  • I’ve cloned the repo to my development machine
  • Changed directory into the freshly cloned ansible directory and ran `source ./hacking/env-setup`.

Finding where to add the filter code

Haversine (wikipedia) is a maths formula, so, it’s probably best to hang out with other Ansible filters such as power, logarithm, min, max and friends.

Grepping through the source for above brought up this:

./lib/ansible/plugins/filter/mathstuff.py

Perfect!  Hopefully.

The code…

Added a function to this file:

def haversine(measurement, lat1, lon1, lat2, lon2):

    from math import radians, sin, cos, sqrt, asin

    diameter = {

        ‘m’: 7917.5,

        ‘km’: 12742}

    try:

        dlat = radians(float(lat2) – float(lat1))

        dlon = radians(float(lon2) – float(lon1))

        lat1 = radians(float(lat1))

        lat2 = radians(float(lat2))

        a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2

        c = 2 * asin(sqrt(a))

    except (ValueError, TypeError) as e:

        raise errors.AnsibleFilterError(‘haversine() only accepts floats: %s’ % str(e))

    if measurement in diameter:

        return round(diameter[measurement] / 2 * c, 2)

    else:

        raise errors.AnsibleFilterError(‘haversine() can only be called with km or m’)

Testing

Ansible has a great test setup.  I started with the unit tests.

Once again, needed to find out where the math stuff testing hung out.  Grep’ing through the test directory found:  test/units/plugins/filter/test_mathstuff.py

Copying the format of existing tests I added a few tests:

class TestHaversine:

    def test_haversine_non_number(self):

        with pytest.raises(AnsibleFilterError, message=’haversine() only accepts floats’):

            ms.haversine(‘km’, ‘a’, ‘b’, ‘c’, ‘d’)

        with pytest.raises(AnsibleFilterError, message=’haversine() only accepts floats’):

            ms.haversine(‘m’, ‘a’, ‘b’, ‘c’, ‘d’)

        with pytest.raises(AnsibleFilterError, message=’haversine() can only be called with km or m’):

            ms.haversine(‘z’, ‘35.9914928’,’-78.907046′, ‘-33.8523063’, ‘151.2085984’)

    def test_km(self):

        assert ms.haversine(‘km’, ‘35.9914928’, ‘-78.907046’, ‘-33.8523063’, ‘151.2085984’) == 15490.46

    def test_m(self):

        assert ms.haversine(‘m’, ‘35.9914928’, ‘-78.907046’, ‘-33.8523063’, ‘151.2085984’) == 9625.31

This was to test:

  • That proper float values were passed through to the function
  • That a proper measure was used (km or m)
  • That two test calculations calculated and rounded nicely

The quickest way to test after making the change was:

ansible-test units --tox --python 2.7 test/units/plugins/filter/test_mathstuff.py

Other types of testing in Ansible were here.

Test playbook

Of course, better test to ensure it’s actually usable in a playbook:

– hosts: localhost

  tasks:

    – name: Haversine distance between two lon/lat co-ordinates

      debug:

        msg: “{{ ‘km’|haversine(‘35.9914928′,’-78.907046′, ‘-33.8523063’, ‘151.2085984’) }}”

    – name: Haversine distance between two lon/lat co-ordinates

      debug:

        msg: “{{ ‘m’|haversine(‘35.9914928′,’-78.907046′, ‘-33.8523063’, ‘151.2085984’) }}”

    – name: Haversine distance between two lon/lat co-ordinates

      debug:

        msg: “{{ ‘m’|haversine(‘a35.9914928′,’-78.907046′, ‘-33.8523063’, ‘151.2085984’) }}”

Which resulted in:

$ ansible-playbook only_filter.yml

PLAY [localhost] *********************************************************************************************************************************************

TASK [Gathering Facts] ***************************************************************************************************************************************

ok: [localhost]

TASK [Haversine distance between two lon/lat co-ordinates] ***************************************************************************************************

ok: [localhost] => {

    “msg”: “15490.46”

}

TASK [Haversine distance between two lon/lat co-ordinates] ***************************************************************************************************

ok: [localhost] => {

    “msg”: “9625.31”

}

TASK [Haversine distance between two lon/lat co-ordinates] ***************************************************************************************************

fatal: [localhost]: FAILED! => {“msg”: “haversine() only accepts floats: could not convert string to float: a35.9914928”}

to retry, use: –limit @/home/johni/ansible/hacking/ansible/only_filter.retry

PLAY RECAP ***************************************************************************************************************************************************

localhost                  : ok=3    changed=0    unreachable=0    failed=1   

Dynamic DNS in less than 25 lines of Ansible YAML and CloudFlare CDN

Overview

The requirement for dynamic DNS has been around for decades and companies like DynDNS have been an enabler for just as long.  Script kiddies, IRC dudes, gamers & professionals often want to host services out of their homes for various reasons, but, may not have a static IP address for their internet connection.  Dynamic DNS services allows the user to update a hostname with a provider to point back to the dynamic IP address allocated to the users modem.  This allows people to reference the domain name record to return the IP address of the modem.

Note: I’m not talking about RFC2136 which includes a dynamic DNS mechanism in the DNS protocol.

I host a few services at home which I like to reach remotely from time to time, and, I’m too tight to pay for a static IP address.   A few years ago this was the task I decided to force myself to solve using python in an attempt to learn.  Whilst ugly, it served its purpose for quite some time until last night when I set myself a task to do this with Ansible in the evening.

The Players

Ansible  is a simple automation tool with use cases across a number of use cases such as Provisioning, Configuration Management, Application Deployment, Orchestration and others. Ansible has plugins and modules which extend it’s functionality.  In this case we are using the ipinfoio_facts and cloudflare_dns modules to query/communicate with…

Cloudflare I see as the Content Delivery Network (CDN) for the people.  Free basic plans,  API interfaces, proxying and DNS management.

ipinfo.io, a neat little site/service to give you geolocation information about where you are browsing from.  This site also returns the data in JSON format if requested, which, makes it nice an easy to query programatically.

A linux Ansible command host to run the Ansible playbooks from…. and setup a crontab to continually run the playbooks.

Some domain names that I have registered with various domain registrars.

The Process (TL;DR)

  1. Ensure you have a domain name to use.
  2. Ensure you have a Cloudflare account, with, the domain name associated.
    1. Take note of your cloudflare API token which is found under My Profile > API Key
  3. Ensure you have a linux box with Ansible installed on it (tested with 2.3.x)
  4. Clone https://github.com/Im0/CloudFlare_DyDNS_Playbook.git
  5. Update the following fields in the cf_dydns_update.yml file
    1. cf_api_token: ‘YOUR API KEY’

    2. cf_email: ‘YOUR CLOUDFLARE EMAIL’

    3. with_items: – The domain names you want to update
  6. Run ansible with:

    ansible-playbook cf_dydns_update.yml

Obviously, you’ll probably want different DNS records updated.  Change the ‘record: mail’ an A record of your choice.

More detail

 


1 ---
2 - hosts: localhost
3 gather_facts: no
4 vars:
5 cf_api_token: 'CF API token under My Profile.. API key'
6 cf_email: 'Cloud Flare email address'
7
8 tasks:
9 - name: get current IP geolocation data
10 ipinfoio_facts:
11 timeout: 5
12 register: ipdata
13
14 # - debug:
15 # var: ipdata.ansible_facts.ip
16
17 - name: Update mail A record
18 cloudflare_dns:
19 zone: '{{ item }}'
20 record: mail
21 type: A
22 value: '{{ ipdata.ansible_facts.ip }}'
23 account_email: '{{ cf_email }}'
24 account_api_token: '{{ cf_api_token }}'
25 register: record
26 with_items:
27 - domain1
28 - domain2
29
30 # - debug:
31 # var: record

 

Breaking down the YML file..

  1. Required at the top of oru YAML files
  2. As we are not configuring any nodes, we set localhost as the only node we want to call against.
  3. As we aren’t using any facts, we don’t need to collect them.
  4. Variables we’re going to need to talk to cloudflare
  5. The API token found under our profile
  6. Our sign up email address for cloudflare
  7. .
  8. The tasks section for all tasks we are going to execute in this playbook
  9. .
  10. Using the ipinfoio_facts module we query ipinfo.io for our externally visible IP address.  Note: If we are being a proxy of some sort this will likely break what we are trying to achieve.
  11. .
  12. This could probably be done a bit better and dropped.  We are registering the output of the module to the ipdata variable.  This could probably be removed as the returned data ends up in the gathered facts which we could use.
  13. .
  14. If we want to see what useful little nuggets of information that have come back, dump the variable contents.
  15. .
  16. .
  17. .
  18. Use the cloudflare_dns module to start talking to cloudflare
  19. Which domain (zone) are we talking about?  In this case we iterate over the domains listed starting line 26 ‘with_items’:
  20. record: is the record we wish to update.
  21. type, is the type of record we are working with.  A few other examples are on the cloudflare_dns module page.
  22. Use the data we received from ipinfoio.  We’ve stashed this away in the data structure: ipdata.ansible_facts.ip
  23. Our cloudflare email
  24. Our cloudflare API key
  25. Capture the output from the cloudflare_dns queries, if we want to dump it in debug later.
  26. With items is a list of items we iterate over… instead of hosts.