diff --git a/plugins/module_utils/gandi_livedns_api.py b/plugins/module_utils/gandi_livedns_api.py new file mode 100644 index 0000000000..60e0761d26 --- /dev/null +++ b/plugins/module_utils/gandi_livedns_api.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019 Gregory Thiemonge +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import json + +from ansible.module_utils._text import to_native, to_text +from ansible.module_utils.urls import fetch_url + + +class GandiLiveDNSAPI(object): + + api_endpoint = 'https://api.gandi.net/v5/livedns' + changed = False + + error_strings = { + 400: 'Bad request', + 401: 'Permission denied', + 404: 'Resource not found', + } + + attribute_map = { + 'record': 'rrset_name', + 'type': 'rrset_type', + 'ttl': 'rrset_ttl', + 'values': 'rrset_values' + } + + def __init__(self, module): + self.module = module + self.api_key = module.params['api_key'] + + def _build_error_message(self, module, info): + s = '' + body = info.get('body') + if body: + errors = module.from_json(body).get('errors') + if errors: + error = errors[0] + name = error.get('name') + if name: + s += '{0} :'.format(name) + description = error.get('description') + if description: + s += description + return s + + def _gandi_api_call(self, api_call, method='GET', payload=None, error_on_404=True): + headers = {'Authorization': 'Apikey {0}'.format(self.api_key), + 'Content-Type': 'application/json'} + data = None + if payload: + try: + data = json.dumps(payload) + except Exception as e: + self.module.fail_json(msg="Failed to encode payload as JSON: %s " % to_native(e)) + + resp, info = fetch_url(self.module, + self.api_endpoint + api_call, + headers=headers, + data=data, + method=method) + + error_msg = '' + if info['status'] >= 400 and (info['status'] != 404 or error_on_404): + err_s = self.error_strings.get(info['status'], '') + + error_msg = "API Error {0}: {1}".format(err_s, self._build_error_message(self.module, info)) + + result = None + try: + content = resp.read() + except AttributeError: + content = None + + if content: + try: + result = json.loads(to_text(content, errors='surrogate_or_strict')) + except (getattr(json, 'JSONDecodeError', ValueError)) as e: + error_msg += "; Failed to parse API response with error {0}: {1}".format(to_native(e), content) + + if error_msg: + self.module.fail_json(msg=error_msg) + + return result, info['status'] + + def build_result(self, result, domain): + if result is None: + return None + + res = {} + for k in self.attribute_map: + v = result.get(self.attribute_map[k], None) + if v is not None: + if k == 'record' and v == '@': + v = '' + res[k] = v + + res['domain'] = domain + + return res + + def build_results(self, results, domain): + if results is None: + return [] + return [self.build_result(r, domain) for r in results] + + def get_records(self, record, type, domain): + url = '/domains/%s/records' % (domain) + if record: + url += '/%s' % (record) + if type: + url += '/%s' % (type) + + records, status = self._gandi_api_call(url, error_on_404=False) + + if status == 404: + return [] + + if not isinstance(records, list): + records = [records] + + # filter by type if record is not set + if not record and type: + records = [r + for r in records + if r['rrset_type'] == type] + + return records + + def create_record(self, record, type, values, ttl, domain): + url = '/domains/%s/records' % (domain) + new_record = { + 'rrset_name': record, + 'rrset_type': type, + 'rrset_values': values, + 'rrset_ttl': ttl, + } + record, status = self._gandi_api_call(url, method='POST', payload=new_record) + + if status in (200, 201,): + return new_record + + return None + + def update_record(self, record, type, values, ttl, domain): + url = '/domains/%s/records/%s/%s' % (domain, record, type) + new_record = { + 'rrset_values': values, + 'rrset_ttl': ttl, + } + record = self._gandi_api_call(url, method='PUT', payload=new_record)[0] + return record + + def delete_record(self, record, type, domain): + url = '/domains/%s/records/%s/%s' % (domain, record, type) + + self._gandi_api_call(url, method='DELETE') + + def delete_dns_record(self, record, type, values, domain): + if record == '': + record = '@' + + records = self.get_records(record, type, domain) + + if records: + cur_record = records[0] + + self.changed = True + + if values is not None and set(cur_record['rrset_values']) != set(values): + new_values = set(cur_record['rrset_values']) - set(values) + if new_values: + # Removing one or more values from a record, we update the record with the remaining values + self.update_record(record, type, list(new_values), cur_record['rrset_ttl'], domain) + records = self.get_records(record, type, domain) + return records[0], self.changed + + if not self.module.check_mode: + self.delete_record(record, type, domain) + else: + cur_record = None + + return None, self.changed + + def ensure_dns_record(self, record, type, ttl, values, domain): + if record == '': + record = '@' + + records = self.get_records(record, type, domain) + + if records: + cur_record = records[0] + + do_update = False + if ttl is not None and cur_record['rrset_ttl'] != ttl: + do_update = True + if values is not None and set(cur_record['rrset_values']) != set(values): + do_update = True + + if do_update: + if self.module.check_mode: + result = dict( + rrset_type=type, + rrset_name=record, + rrset_values=values, + rrset_ttl=ttl + ) + else: + self.update_record(record, type, values, ttl, domain) + + records = self.get_records(record, type, domain) + result = records[0] + self.changed = True + return result, self.changed + else: + return cur_record, self.changed + + if self.module.check_mode: + new_record = dict( + rrset_type=type, + rrset_name=record, + rrset_values=values, + rrset_ttl=ttl + ) + result = new_record + else: + result = self.create_record(record, type, values, ttl, domain) + + self.changed = True + return result, self.changed diff --git a/plugins/modules/gandi_livedns.py b/plugins/modules/gandi_livedns.py new file mode 120000 index 0000000000..6a8a82fab7 --- /dev/null +++ b/plugins/modules/gandi_livedns.py @@ -0,0 +1 @@ +net_tools/gandi_livedns.py \ No newline at end of file diff --git a/plugins/modules/net_tools/gandi_livedns.py b/plugins/modules/net_tools/gandi_livedns.py new file mode 100644 index 0000000000..6124288511 --- /dev/null +++ b/plugins/modules/net_tools/gandi_livedns.py @@ -0,0 +1,187 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019 Gregory Thiemonge +# 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: gandi_livedns +author: +- Gregory Thiemonge (@gthiemonge) +version_added: "2.3.0" +short_description: Manage Gandi LiveDNS records +description: +- "Manages DNS records by the Gandi LiveDNS API, see the docs: U(https://doc.livedns.gandi.net/)." +options: + api_key: + description: + - Account API token. + type: str + required: true + record: + description: + - Record to add. + type: str + required: true + state: + description: + - Whether the record(s) should exist or not. + type: str + choices: [ absent, present ] + default: present + ttl: + description: + - The TTL to give the new record. + - Required when I(state=present). + type: int + type: + description: + - The type of DNS record to create. + type: str + required: true + values: + description: + - The record values. + - Required when I(state=present). + type: list + elements: str + domain: + description: + - The name of the Domain to work with (for example, "example.com"). + required: true + type: str +notes: +- Supports C(check_mode). +''' + +EXAMPLES = r''' +- name: Create a test A record to point to 127.0.0.1 in the my.com domain + community.general.gandi_livedns: + domain: my.com + record: test + type: A + values: + - 127.0.0.1 + ttl: 7200 + api_key: dummyapitoken + register: record + +- name: Create a mail CNAME record to www.my.com domain + community.general.gandi_livedns: + domain: my.com + type: CNAME + record: mail + values: + - www + ttl: 7200 + api_key: dummyapitoken + state: present + +- name: Change its TTL + community.general.gandi_livedns: + domain: my.com + type: CNAME + record: mail + values: + - www + ttl: 10800 + api_key: dummyapitoken + state: present + +- name: Delete the record + community.general.gandi_livedns: + domain: my.com + type: CNAME + record: mail + api_key: dummyapitoken + state: absent +''' + +RETURN = r''' +record: + description: A dictionary containing the record data. + returned: success, except on record deletion + type: dict + contains: + values: + description: The record content (details depend on record type). + returned: success + type: list + elements: str + sample: + - 192.0.2.91 + - 192.0.2.92 + record: + description: The record name. + returned: success + type: str + sample: www + ttl: + description: The time-to-live for the record. + returned: success + type: int + sample: 300 + type: + description: The record type. + returned: success + type: str + sample: A + domain: + description: The domain associated with the record. + returned: success + type: str + sample: my.com +''' + + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.gandi_livedns_api import GandiLiveDNSAPI + + +def main(): + module = AnsibleModule( + argument_spec=dict( + api_key=dict(type='str', required=True, no_log=True), + record=dict(type='str', required=True), + state=dict(type='str', default='present', choices=['absent', 'present']), + ttl=dict(type='int'), + type=dict(type='str', required=True), + values=dict(type='list', elements='str'), + domain=dict(type='str', required=True), + ), + supports_check_mode=True, + required_if=[ + ('state', 'present', ['values', 'ttl']), + ], + ) + + gandi_api = GandiLiveDNSAPI(module) + + if module.params['state'] == 'present': + ret, changed = gandi_api.ensure_dns_record(module.params['record'], + module.params['type'], + module.params['ttl'], + module.params['values'], + module.params['domain']) + else: + ret, changed = gandi_api.delete_dns_record(module.params['record'], + module.params['type'], + module.params['values'], + module.params['domain']) + + result = dict( + changed=changed, + ) + if ret: + result['record'] = gandi_api.build_result(ret, + module.params['domain']) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/gandi_livedns/aliases b/tests/integration/targets/gandi_livedns/aliases new file mode 100644 index 0000000000..3ff69ca3a0 --- /dev/null +++ b/tests/integration/targets/gandi_livedns/aliases @@ -0,0 +1,2 @@ +cloud/gandi +unsupported diff --git a/tests/integration/targets/gandi_livedns/defaults/main.yml b/tests/integration/targets/gandi_livedns/defaults/main.yml new file mode 100644 index 0000000000..d27842ae0b --- /dev/null +++ b/tests/integration/targets/gandi_livedns/defaults/main.yml @@ -0,0 +1,34 @@ +# Copyright: (c) 2020 Gregory Thiemonge +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +gandi_livedns_domain_name: "ansible-tests.org" +gandi_livedns_record_items: +# Single A record +- record: test-www + type: A + values: + - 10.10.10.10 + ttl: 400 + update_values: + - 10.10.10.11 + update_ttl: 800 + +# Multiple A records +- record: test-www-multiple + type: A + ttl: 3600 + values: + - 10.10.11.10 + - 10.10.11.10 + update_values: + - 10.10.11.11 + - 10.10.11.13 + +# CNAME +- record: test-cname + type: CNAME + ttl: 10800 + values: + - test-www2 + update_values: + - test-www diff --git a/tests/integration/targets/gandi_livedns/tasks/create_record.yml b/tests/integration/targets/gandi_livedns/tasks/create_record.yml new file mode 100644 index 0000000000..bfaff81393 --- /dev/null +++ b/tests/integration/targets/gandi_livedns/tasks/create_record.yml @@ -0,0 +1,67 @@ +# Copyright: (c) 2020 Gregory Thiemonge +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: test absent dns record + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + type: "{{ item.type }}" + ttl: "{{ item.ttl }}" + state: absent + register: result +- name: verify test absent dns record + assert: + that: + - result is successful + +- name: test create a dns record in check mode + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item['values'] }}" + ttl: "{{ item.ttl }}" + type: "{{ item.type }}" + check_mode: yes + register: result +- name: verify test create a dns record in check mode + assert: + that: + - result is changed + +- name: test create a dns record + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item['values'] }}" + ttl: "{{ item.ttl }}" + type: "{{ item.type }}" + register: result +- name: verify test create a dns record + assert: + that: + - result is changed + - result.record['values'] == {{ item['values'] }} + - result.record.record == "{{ item.record }}" + - result.record.type == "{{ item.type }}" + - result.record.ttl == {{ item.ttl }} + +- name: test create a dns record idempotence + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item['values'] }}" + ttl: "{{ item.ttl }}" + type: "{{ item.type }}" + register: result +- name: verify test create a dns record idempotence + assert: + that: + - result is not changed + - result.record['values'] == {{ item['values'] }} + - result.record.record == "{{ item.record }}" + - result.record.type == "{{ item.type }}" + - result.record.ttl == {{ item.ttl }} diff --git a/tests/integration/targets/gandi_livedns/tasks/main.yml b/tests/integration/targets/gandi_livedns/tasks/main.yml new file mode 100644 index 0000000000..5b11e8b9f3 --- /dev/null +++ b/tests/integration/targets/gandi_livedns/tasks/main.yml @@ -0,0 +1,5 @@ +# Copyright: (c) 2020 Gregory Thiemonge +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- include_tasks: record.yml + with_items: "{{ gandi_livedns_record_items }}" diff --git a/tests/integration/targets/gandi_livedns/tasks/record.yml b/tests/integration/targets/gandi_livedns/tasks/record.yml new file mode 100644 index 0000000000..1e5977e3f9 --- /dev/null +++ b/tests/integration/targets/gandi_livedns/tasks/record.yml @@ -0,0 +1,6 @@ +# Copyright: (c) 2020 Gregory Thiemonge +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- include_tasks: create_record.yml +- include_tasks: update_record.yml +- include_tasks: remove_record.yml diff --git a/tests/integration/targets/gandi_livedns/tasks/remove_record.yml b/tests/integration/targets/gandi_livedns/tasks/remove_record.yml new file mode 100644 index 0000000000..78a0d2f42b --- /dev/null +++ b/tests/integration/targets/gandi_livedns/tasks/remove_record.yml @@ -0,0 +1,59 @@ +# Copyright: (c) 2020 Gregory Thiemonge +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: test remove a dns record in check mode + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item.update_values | default(item['values']) }}" + type: "{{ item.type }}" + state: absent + check_mode: yes + register: result +- name: verify test remove a dns record in check mode + assert: + that: + - result is changed + +- name: test remove a dns record + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item.update_values | default(item['values']) }}" + type: "{{ item.type }}" + state: absent + register: result +- name: verify test remove a dns record + assert: + that: + - result is changed + +- name: test remove a dns record idempotence + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item.update_values | default(item['values']) }}" + type: "{{ item.type }}" + state: absent + register: result +- name: verify test remove a dns record idempotence + assert: + that: + - result is not changed + +- name: test remove second dns record idempotence + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item['values'] }}" + type: "{{ item.type }}" + state: absent + register: result +- name: verify test remove a dns record idempotence + assert: + that: + - result is not changed diff --git a/tests/integration/targets/gandi_livedns/tasks/update_record.yml b/tests/integration/targets/gandi_livedns/tasks/update_record.yml new file mode 100644 index 0000000000..fdb1dc1e23 --- /dev/null +++ b/tests/integration/targets/gandi_livedns/tasks/update_record.yml @@ -0,0 +1,57 @@ +# Copyright: (c) 2020 Gregory Thiemonge +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: test update or add another dns record in check mode + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item.update_values | default(item['values']) }}" + ttl: "{{ item.update_ttl | default(item.ttl) }}" + type: "{{ item.type }}" + check_mode: yes + register: result +- name: verify test update in check mode + assert: + that: + - result is changed + - result.record['values'] == {{ item.update_values | default(item['values']) }} + - result.record.record == "{{ item.record }}" + - result.record.type == "{{ item.type }}" + - result.record.ttl == {{ item.update_ttl | default(item.ttl) }} + +- name: test update or add another dns record + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item.update_values | default(item['values']) }}" + ttl: "{{ item.update_ttl | default(item.ttl) }}" + type: "{{ item.type }}" + register: result +- name: verify test update a dns record + assert: + that: + - result is changed + - result.record['values'] == {{ item.update_values | default(item['values']) }} + - result.record.record == "{{ item.record }}" + - result.record.ttl == {{ item.update_ttl | default(item.ttl) }} + - result.record.type == "{{ item.type }}" + +- name: test update or add another dns record idempotence + community.general.gandi_livedns: + api_key: "{{ gandi_api_key }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item.update_values | default(item['values']) }}" + ttl: "{{ item.update_ttl | default(item.ttl) }}" + type: "{{ item.type }}" + register: result +- name: verify test update a dns record idempotence + assert: + that: + - result is not changed + - result.record['values'] == {{ item.update_values | default(item['values']) }} + - result.record.record == "{{ item.record }}" + - result.record.ttl == {{ item.update_ttl | default(item.ttl) }} + - result.record.type == "{{ item.type }}"