1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3#
4# Copyright: Ansible Project
5#
6# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
7
8from __future__ import absolute_import, division, print_function
9__metaclass__ = type
10
11
12DOCUMENTATION = '''
13---
14module: dnsimple
15short_description: Interface with dnsimple.com (a DNS hosting service)
16description:
17   - "Manages domains and records via the DNSimple API, see the docs: U(http://developer.dnsimple.com/)."
18options:
19  account_email:
20    description:
21      - Account email. If omitted, the environment variables C(DNSIMPLE_EMAIL) and C(DNSIMPLE_API_TOKEN) will be looked for.
22      - "If those aren't found, a C(.dnsimple) file will be looked for, see: U(https://github.com/mikemaccana/dnsimple-python#getting-started)."
23      - "C(.dnsimple) config files are only supported in dnsimple-python<2.0.0"
24    type: str
25  account_api_token:
26    description:
27      - Account API token. See I(account_email) for more information.
28    type: str
29  domain:
30    description:
31      - Domain to work with. Can be the domain name (e.g. "mydomain.com") or the numeric ID of the domain in DNSimple.
32      - If omitted, a list of domains will be returned.
33      - If domain is present but the domain doesn't exist, it will be created.
34    type: str
35  record:
36    description:
37      - Record to add, if blank a record for the domain will be created, supports the wildcard (*).
38    type: str
39  record_ids:
40    description:
41      - List of records to ensure they either exist or do not exist.
42    type: list
43    elements: str
44  type:
45    description:
46      - The type of DNS record to create.
47    choices: [ 'A', 'ALIAS', 'CNAME', 'MX', 'SPF', 'URL', 'TXT', 'NS', 'SRV', 'NAPTR', 'PTR', 'AAAA', 'SSHFP', 'HINFO', 'POOL', 'CAA' ]
48    type: str
49  ttl:
50    description:
51      - The TTL to give the new record in seconds.
52    default: 3600
53    type: int
54  value:
55    description:
56      - Record value.
57      - Must be specified when trying to ensure a record exists.
58    type: str
59  priority:
60    description:
61      - Record priority.
62    type: int
63  state:
64    description:
65      - whether the record should exist or not.
66    choices: [ 'present', 'absent' ]
67    default: present
68    type: str
69  solo:
70    description:
71      - Whether the record should be the only one for that record type and record name.
72      - Only use with C(state) is set to C(present) on a record.
73    type: 'bool'
74    default: no
75  sandbox:
76    description:
77      - Use the DNSimple sandbox environment.
78      - Requires a dedicated account in the dnsimple sandbox environment.
79      - Check U(https://developer.dnsimple.com/sandbox/) for more information.
80    type: 'bool'
81    default: no
82    version_added: 3.5.0
83requirements:
84  - "dnsimple >= 1.0.0"
85author: "Alex Coomans (@drcapulet)"
86'''
87
88EXAMPLES = '''
89- name: Authenticate using email and API token and fetch all domains
90  community.general.dnsimple:
91    account_email: test@example.com
92    account_api_token: dummyapitoken
93  delegate_to: localhost
94
95- name: Fetch my.com domain records
96  community.general.dnsimple:
97    domain: my.com
98    state: present
99  delegate_to: localhost
100  register: records
101
102- name: Delete a domain
103  community.general.dnsimple:
104    domain: my.com
105    state: absent
106  delegate_to: localhost
107
108- name: Create a test.my.com A record to point to 127.0.0.1
109  community.general.dnsimple:
110    domain: my.com
111    record: test
112    type: A
113    value: 127.0.0.1
114  delegate_to: localhost
115  register: record
116
117- name: Delete record using record_ids
118  community.general.dnsimple:
119    domain: my.com
120    record_ids: '{{ record["id"] }}'
121    state: absent
122  delegate_to: localhost
123
124- name: Create a my.com CNAME record to example.com
125  community.general.dnsimple:
126    domain: my.com
127    record: ''
128    type: CNAME
129    value: example.com
130    state: present
131  delegate_to: localhost
132
133- name: Change TTL value for a record
134  community.general.dnsimple:
135    domain: my.com
136    record: ''
137    type: CNAME
138    value: example.com
139    ttl: 600
140    state: present
141  delegate_to: localhost
142
143- name: Delete the record
144  community.general.dnsimple:
145    domain: my.com
146    record: ''
147    type: CNAME
148    value: example.com
149    state: absent
150  delegate_to: localhost
151'''
152
153RETURN = r"""# """
154
155import traceback
156from distutils.version import LooseVersion
157import re
158
159
160class DNSimpleV1():
161    """class which uses dnsimple-python < 2"""
162
163    def __init__(self, account_email, account_api_token, sandbox, module):
164        """init"""
165        self.module = module
166        self.account_email = account_email
167        self.account_api_token = account_api_token
168        self.sandbox = sandbox
169        self.dnsimple_client()
170
171    def dnsimple_client(self):
172        """creates a dnsimple client object"""
173        if self.account_email and self.account_api_token:
174            self.client = DNSimple(sandbox=self.sandbox, email=self.account_email, api_token=self.account_api_token)
175        else:
176            self.client = DNSimple(sandbox=self.sandbox)
177
178    def get_all_domains(self):
179        """returns a list of all domains"""
180        domain_list = self.client.domains()
181        return [d['domain'] for d in domain_list]
182
183    def get_domain(self, domain):
184        """returns a single domain by name or id"""
185        try:
186            dr = self.client.domain(domain)['domain']
187        except DNSimpleException as e:
188            exception_string = str(e.args[0]['message'])
189            if re.match(r"^Domain .+ not found$", exception_string):
190                dr = None
191            else:
192                raise
193        return dr
194
195    def create_domain(self, domain):
196        """create a single domain"""
197        return self.client.add_domain(domain)['domain']
198
199    def delete_domain(self, domain):
200        """delete a single domain"""
201        self.client.delete(domain)
202
203    def get_records(self, domain, dnsimple_filter=None):
204        """return dns ressource records which match a specified filter"""
205        return [r['record'] for r in self.client.records(str(domain), params=dnsimple_filter)]
206
207    def delete_record(self, domain, rid):
208        """delete a single dns ressource record"""
209        self.client.delete_record(str(domain), rid)
210
211    def update_record(self, domain, rid, ttl=None, priority=None):
212        """update a single dns ressource record"""
213        data = {}
214        if ttl:
215            data['ttl'] = ttl
216        if priority:
217            data['priority'] = priority
218        return self.client.update_record(str(domain), str(rid), data)['record']
219
220    def create_record(self, domain, name, record_type, content, ttl=None, priority=None):
221        """create a single dns ressource record"""
222        data = {
223            'name': name,
224            'type': record_type,
225            'content': content,
226        }
227        if ttl:
228            data['ttl'] = ttl
229        if priority:
230            data['priority'] = priority
231        return self.client.add_record(str(domain), data)['record']
232
233
234class DNSimpleV2():
235    """class which uses dnsimple-python >= 2"""
236
237    def __init__(self, account_email, account_api_token, sandbox, module):
238        """init"""
239        self.module = module
240        self.account_email = account_email
241        self.account_api_token = account_api_token
242        self.sandbox = sandbox
243        self.pagination_per_page = 30
244        self.dnsimple_client()
245        self.dnsimple_account()
246
247    def dnsimple_client(self):
248        """creates a dnsimple client object"""
249        if self.account_email and self.account_api_token:
250            client = Client(sandbox=self.sandbox, email=self.account_email, access_token=self.account_api_token)
251        else:
252            msg = "Option account_email or account_api_token not provided. " \
253                  "Dnsimple authentiction with a .dnsimple config file is not " \
254                  "supported with dnsimple-python>=2.0.0"
255            raise DNSimpleException(msg)
256        client.identity.whoami()
257        self.client = client
258
259    def dnsimple_account(self):
260        """select a dnsimple account. If a user token is used for authentication,
261        this user must only have access to a single account"""
262        account = self.client.identity.whoami().data.account
263        # user supplied a user token instead of account api token
264        if not account:
265            accounts = Accounts(self.client).list_accounts().data
266            if len(accounts) != 1:
267                msg = "The provided dnsimple token is a user token with multiple accounts." \
268                    "Use an account token or a user token with access to a single account." \
269                    "See https://support.dnsimple.com/articles/api-access-token/"
270                raise DNSimpleException(msg)
271            account = accounts[0]
272        self.account = account
273
274    def get_all_domains(self):
275        """returns a list of all domains"""
276        domain_list = self._get_paginated_result(self.client.domains.list_domains, account_id=self.account.id)
277        return [d.__dict__ for d in domain_list]
278
279    def get_domain(self, domain):
280        """returns a single domain by name or id"""
281        try:
282            dr = self.client.domains.get_domain(self.account.id, domain).data.__dict__
283        except DNSimpleException as e:
284            exception_string = str(e.message)
285            if re.match(r"^Domain .+ not found$", exception_string):
286                dr = None
287            else:
288                raise
289        return dr
290
291    def create_domain(self, domain):
292        """create a single domain"""
293        return self.client.domains.create_domain(self.account.id, domain).data.__dict__
294
295    def delete_domain(self, domain):
296        """delete a single domain"""
297        self.client.domains.delete_domain(self.account.id, domain)
298
299    def get_records(self, zone, dnsimple_filter=None):
300        """return dns ressource records which match a specified filter"""
301        records_list = self._get_paginated_result(self.client.zones.list_records,
302                                                  account_id=self.account.id,
303                                                  zone=zone, filter=dnsimple_filter)
304        return [d.__dict__ for d in records_list]
305
306    def delete_record(self, domain, rid):
307        """delete a single dns ressource record"""
308        self.client.zones.delete_record(self.account.id, domain, rid)
309
310    def update_record(self, domain, rid, ttl=None, priority=None):
311        """update a single dns ressource record"""
312        zr = ZoneRecordUpdateInput(ttl=ttl, priority=priority)
313        result = self.client.zones.update_record(self.account.id, str(domain), str(rid), zr).data.__dict__
314        return result
315
316    def create_record(self, domain, name, record_type, content, ttl=None, priority=None):
317        """create a single dns ressource record"""
318        zr = ZoneRecordInput(name=name, type=record_type, content=content, ttl=ttl, priority=priority)
319        return self.client.zones.create_record(self.account.id, str(domain), zr).data.__dict__
320
321    def _get_paginated_result(self, operation, **options):
322        """return all results of a paginated api response"""
323        records_pagination = operation(per_page=self.pagination_per_page, **options).pagination
324        result_list = []
325        for page in range(1, records_pagination.total_pages + 1):
326            page_data = operation(per_page=self.pagination_per_page, page=page, **options).data
327            result_list.extend(page_data)
328        return result_list
329
330
331DNSIMPLE_IMP_ERR = []
332HAS_DNSIMPLE = False
333try:
334    # try to import dnsimple >= 2.0.0
335    from dnsimple import Client, DNSimpleException
336    from dnsimple.service import Accounts
337    from dnsimple.version import version as dnsimple_version
338    from dnsimple.struct.zone_record import ZoneRecordUpdateInput, ZoneRecordInput
339    HAS_DNSIMPLE = True
340except ImportError:
341    DNSIMPLE_IMP_ERR.append(traceback.format_exc())
342
343if not HAS_DNSIMPLE:
344    # try to import dnsimple < 2.0.0
345    try:
346        from dnsimple.dnsimple import __version__ as dnsimple_version
347        from dnsimple import DNSimple
348        from dnsimple.dnsimple import DNSimpleException
349        HAS_DNSIMPLE = True
350    except ImportError:
351        DNSIMPLE_IMP_ERR.append(traceback.format_exc())
352
353from ansible.module_utils.basic import AnsibleModule, missing_required_lib, env_fallback
354
355
356def main():
357    module = AnsibleModule(
358        argument_spec=dict(
359            account_email=dict(type='str', fallback=(env_fallback, ['DNSIMPLE_EMAIL'])),
360            account_api_token=dict(type='str',
361                                   no_log=True,
362                                   fallback=(env_fallback, ['DNSIMPLE_API_TOKEN'])),
363            domain=dict(type='str'),
364            record=dict(type='str'),
365            record_ids=dict(type='list', elements='str'),
366            type=dict(type='str', choices=['A', 'ALIAS', 'CNAME', 'MX', 'SPF',
367                                           'URL', 'TXT', 'NS', 'SRV', 'NAPTR',
368                                           'PTR', 'AAAA', 'SSHFP', 'HINFO',
369                                           'POOL', 'CAA']),
370            ttl=dict(type='int', default=3600),
371            value=dict(type='str'),
372            priority=dict(type='int'),
373            state=dict(type='str', choices=['present', 'absent'], default='present'),
374            solo=dict(type='bool', default=False),
375            sandbox=dict(type='bool', default=False),
376        ),
377        required_together=[
378            ['record', 'value']
379        ],
380        supports_check_mode=True,
381    )
382
383    if not HAS_DNSIMPLE:
384        module.fail_json(msg=missing_required_lib('dnsimple'), exception=DNSIMPLE_IMP_ERR[0])
385
386    account_email = module.params.get('account_email')
387    account_api_token = module.params.get('account_api_token')
388    domain = module.params.get('domain')
389    record = module.params.get('record')
390    record_ids = module.params.get('record_ids')
391    record_type = module.params.get('type')
392    ttl = module.params.get('ttl')
393    value = module.params.get('value')
394    priority = module.params.get('priority')
395    state = module.params.get('state')
396    is_solo = module.params.get('solo')
397    sandbox = module.params.get('sandbox')
398
399    DNSIMPLE_MAJOR_VERSION = LooseVersion(dnsimple_version).version[0]
400
401    try:
402        if DNSIMPLE_MAJOR_VERSION > 1:
403            ds = DNSimpleV2(account_email, account_api_token, sandbox, module)
404        else:
405            ds = DNSimpleV1(account_email, account_api_token, sandbox, module)
406        # Let's figure out what operation we want to do
407        # No domain, return a list
408        if not domain:
409            all_domains = ds.get_all_domains()
410            module.exit_json(changed=False, result=all_domains)
411
412        # Domain & No record
413        if record is None and not record_ids:
414            if domain.isdigit():
415                typed_domain = int(domain)
416            else:
417                typed_domain = str(domain)
418            dr = ds.get_domain(typed_domain)
419            # domain does not exist
420            if state == 'present':
421                if dr:
422                    module.exit_json(changed=False, result=dr)
423                else:
424                    if module.check_mode:
425                        module.exit_json(changed=True)
426                    else:
427                        response = ds.create_domain(domain)
428                        module.exit_json(changed=True, result=response)
429            # state is absent
430            else:
431                if dr:
432                    if not module.check_mode:
433                        ds.delete_domain(domain)
434                    module.exit_json(changed=True)
435                else:
436                    module.exit_json(changed=False)
437
438        # need the not none check since record could be an empty string
439        if record is not None:
440            if not record_type:
441                module.fail_json(msg="Missing the record type")
442            if not value:
443                module.fail_json(msg="Missing the record value")
444
445            records_list = ds.get_records(domain, dnsimple_filter={'name': record})
446            rr = next((r for r in records_list if r['name'] == record and r['type'] == record_type and r['content'] == value), None)
447            if state == 'present':
448                changed = False
449                if is_solo:
450                    # delete any records that have the same name and record type
451                    same_type = [r['id'] for r in records_list if r['name'] == record and r['type'] == record_type]
452                    if rr:
453                        same_type = [rid for rid in same_type if rid != rr['id']]
454                    if same_type:
455                        if not module.check_mode:
456                            for rid in same_type:
457                                ds.delete_record(domain, rid)
458                        changed = True
459                if rr:
460                    # check if we need to update
461                    if rr['ttl'] != ttl or rr['priority'] != priority:
462                        if module.check_mode:
463                            module.exit_json(changed=True)
464                        else:
465                            response = ds.update_record(domain, rr['id'], ttl, priority)
466                            module.exit_json(changed=True, result=response)
467                    else:
468                        module.exit_json(changed=changed, result=rr)
469                else:
470                    # create it
471                    if module.check_mode:
472                        module.exit_json(changed=True)
473                    else:
474                        response = ds.create_record(domain, record, record_type, value, ttl, priority)
475                        module.exit_json(changed=True, result=response)
476            # state is absent
477            else:
478                if rr:
479                    if not module.check_mode:
480                        ds.delete_record(domain, rr['id'])
481                    module.exit_json(changed=True)
482                else:
483                    module.exit_json(changed=False)
484
485        # Make sure these record_ids either all exist or none
486        if record_ids:
487            current_records = ds.get_records(domain, dnsimple_filter=None)
488            current_record_ids = [str(d['id']) for d in current_records]
489            wanted_record_ids = [str(r) for r in record_ids]
490            if state == 'present':
491                difference = list(set(wanted_record_ids) - set(current_record_ids))
492                if difference:
493                    module.fail_json(msg="Missing the following records: %s" % difference)
494                else:
495                    module.exit_json(changed=False)
496            # state is absent
497            else:
498                difference = list(set(wanted_record_ids) & set(current_record_ids))
499                if difference:
500                    if not module.check_mode:
501                        for rid in difference:
502                            ds.delete_record(domain, rid)
503                    module.exit_json(changed=True)
504                else:
505                    module.exit_json(changed=False)
506
507    except DNSimpleException as e:
508        if DNSIMPLE_MAJOR_VERSION > 1:
509            module.fail_json(msg="DNSimple exception: %s" % e.message)
510        else:
511            module.fail_json(msg="DNSimple exception: %s" % str(e.args[0]['message']))
512    module.fail_json(msg="Unknown what you wanted me to do")
513
514
515if __name__ == '__main__':
516    main()
517