#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2016 Michael Gruener # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = r''' --- module: cloudflare_dns author: - Michael Gruener (@mgruener) requirements: - python >= 2.6 short_description: Manage Cloudflare DNS records description: - "Manages dns records via the Cloudflare API, see the docs: U(https://api.cloudflare.com/)." options: api_token: description: - API token. - Required for api token authentication. - "You can obtain your API token from the bottom of the Cloudflare 'My Account' page, found here: U(https://dash.cloudflare.com/)." - Can be specified in C(CLOUDFLARE_TOKEN) environment variable since community.general 2.0.0. type: str required: false version_added: '0.2.0' account_api_key: description: - Account API key. - Required for api keys authentication. - "You can obtain your API key from the bottom of the Cloudflare 'My Account' page, found here: U(https://dash.cloudflare.com/)." type: str required: false aliases: [ account_api_token ] account_email: description: - Account email. Required for API keys authentication. type: str required: false algorithm: description: - Algorithm number. - Required for C(type=DS) and C(type=SSHFP) when C(state=present). type: int cert_usage: description: - Certificate usage number. - Required for C(type=TLSA) when C(state=present). type: int choices: [ 0, 1, 2, 3 ] hash_type: description: - Hash type number. - Required for C(type=DS), C(type=SSHFP) and C(type=TLSA) when C(state=present). type: int choices: [ 1, 2 ] key_tag: description: - DNSSEC key tag. - Needed for C(type=DS) when C(state=present). type: int port: description: - Service port. - Required for C(type=SRV) and C(type=TLSA). type: int priority: description: - Record priority. - Required for C(type=MX) and C(type=SRV) default: 1 type: int proto: description: - Service protocol. Required for C(type=SRV) and C(type=TLSA). - Common values are TCP and UDP. - Before Ansible 2.6 only TCP and UDP were available. type: str proxied: description: - Proxy through Cloudflare network or just use DNS. type: bool default: false record: description: - Record to add. - Required if C(state=present). - Default is C(@) (e.g. the zone name). type: str default: '@' aliases: [ name ] selector: description: - Selector number. - Required for C(type=TLSA) when C(state=present). choices: [ 0, 1 ] type: int service: description: - Record service. - Required for I(type=SRV). type: str solo: description: - Whether the record should be the only one for that record type and record name. - Only use with C(state=present). - This will delete all other records with the same record name and type. type: bool state: description: - Whether the record(s) should exist or not. type: str choices: [ absent, present ] default: present timeout: description: - Timeout for Cloudflare API calls. type: int default: 30 ttl: description: - The TTL to give the new record. - Must be between 120 and 2,147,483,647 seconds, or 1 for automatic. type: int default: 1 type: description: - The type of DNS record to create. Required if C(state=present). - C(type=DS), C(type=SSHFP) and C(type=TLSA) added in Ansible 2.7. type: str choices: [ A, AAAA, CNAME, DS, MX, NS, SPF, SRV, SSHFP, TLSA, TXT ] value: description: - The record value. - Required for C(state=present). type: str aliases: [ content ] weight: description: - Service weight. - Required for C(type=SRV). type: int default: 1 zone: description: - The name of the Zone to work with (e.g. "example.com"). - The Zone must already exist. type: str required: true aliases: [ domain ] ''' EXAMPLES = r''' - name: Create a test.example.net A record to point to 127.0.0.1 community.general.cloudflare_dns: zone: example.net record: test type: A value: 127.0.0.1 account_email: test@example.com account_api_key: dummyapitoken register: record - name: Create a record using api token community.general.cloudflare_dns: zone: example.net record: test type: A value: 127.0.0.1 api_token: dummyapitoken - name: Create a example.net CNAME record to example.com community.general.cloudflare_dns: zone: example.net type: CNAME value: example.com account_email: test@example.com account_api_key: dummyapitoken state: present - name: Change its TTL community.general.cloudflare_dns: zone: example.net type: CNAME value: example.com ttl: 600 account_email: test@example.com account_api_key: dummyapitoken state: present - name: Delete the record community.general.cloudflare_dns: zone: example.net type: CNAME value: example.com account_email: test@example.com account_api_key: dummyapitoken state: absent - name: Create a example.net CNAME record to example.com and proxy through Cloudflare's network community.general.cloudflare_dns: zone: example.net type: CNAME value: example.com proxied: true account_email: test@example.com account_api_key: dummyapitoken state: present # This deletes all other TXT records named "test.example.net" - name: Create TXT record "test.example.net" with value "unique value" community.general.cloudflare_dns: domain: example.net record: test type: TXT value: unique value solo: true account_email: test@example.com account_api_key: dummyapitoken state: present - name: Create an SRV record _foo._tcp.example.net community.general.cloudflare_dns: domain: example.net service: foo proto: tcp port: 3500 priority: 10 weight: 20 type: SRV value: fooserver.example.net - name: Create a SSHFP record login.example.com community.general.cloudflare_dns: zone: example.com record: login type: SSHFP algorithm: 4 hash_type: 2 value: 9dc1d6742696d2f51ca1f1a78b3d16a840f7d111eb9454239e70db31363f33e1 - name: Create a TLSA record _25._tcp.mail.example.com community.general.cloudflare_dns: zone: example.com record: mail port: 25 proto: tcp type: TLSA cert_usage: 3 selector: 1 hash_type: 1 value: 6b76d034492b493e15a7376fccd08e63befdad0edab8e442562f532338364bf3 - name: Create a DS record for subdomain.example.com community.general.cloudflare_dns: zone: example.com record: subdomain type: DS key_tag: 5464 algorithm: 8 hash_type: 2 value: B4EB5AC4467D2DFB3BAF9FB9961DC1B6FED54A58CDFAA3E465081EC86F89BFAB ''' RETURN = r''' record: description: A dictionary containing the record data. returned: success, except on record deletion type: complex contains: content: description: The record content (details depend on record type). returned: success type: str sample: 192.0.2.91 created_on: description: The record creation date. returned: success type: str sample: "2016-03-25T19:09:42.516553Z" data: description: Additional record data. returned: success, if type is SRV, DS, SSHFP or TLSA type: dict sample: { name: "jabber", port: 8080, priority: 10, proto: "_tcp", service: "_xmpp", target: "jabberhost.sample.com", weight: 5, } id: description: The record ID. returned: success type: str sample: f9efb0549e96abcb750de63b38c9576e locked: description: No documentation available. returned: success type: bool sample: False meta: description: No documentation available. returned: success type: dict sample: { auto_added: false } modified_on: description: Record modification date. returned: success type: str sample: "2016-03-25T19:09:42.516553Z" name: description: The record name as FQDN (including _service and _proto for SRV). returned: success type: str sample: www.sample.com priority: description: Priority of the MX record. returned: success, if type is MX type: int sample: 10 proxiable: description: Whether this record can be proxied through Cloudflare. returned: success type: bool sample: False proxied: description: Whether the record is proxied through Cloudflare. returned: success type: bool sample: False 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 zone_id: description: The ID of the zone containing the record. returned: success type: str sample: abcede0bf9f0066f94029d2e6b73856a zone_name: description: The name of the zone containing the record. returned: success type: str sample: sample.com ''' import json from ansible.module_utils.basic import AnsibleModule, env_fallback from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible.module_utils.common.text.converters import to_native, to_text from ansible.module_utils.urls import fetch_url def lowercase_string(param): if not isinstance(param, str): return param return param.lower() class CloudflareAPI(object): cf_api_endpoint = 'https://api.cloudflare.com/client/v4' changed = False def __init__(self, module): self.module = module self.api_token = module.params['api_token'] self.account_api_key = module.params['account_api_key'] self.account_email = module.params['account_email'] self.algorithm = module.params['algorithm'] self.cert_usage = module.params['cert_usage'] self.hash_type = module.params['hash_type'] self.key_tag = module.params['key_tag'] self.port = module.params['port'] self.priority = module.params['priority'] self.proto = lowercase_string(module.params['proto']) self.proxied = module.params['proxied'] self.selector = module.params['selector'] self.record = lowercase_string(module.params['record']) self.service = lowercase_string(module.params['service']) self.is_solo = module.params['solo'] self.state = module.params['state'] self.timeout = module.params['timeout'] self.ttl = module.params['ttl'] self.type = module.params['type'] self.value = module.params['value'] self.weight = module.params['weight'] self.zone = lowercase_string(module.params['zone']) if self.record == '@': self.record = self.zone if (self.type in ['CNAME', 'NS', 'MX', 'SRV']) and (self.value is not None): self.value = self.value.rstrip('.').lower() if (self.type == 'AAAA') and (self.value is not None): self.value = self.value.lower() if (self.type == 'SRV'): if (self.proto is not None) and (not self.proto.startswith('_')): self.proto = '_' + self.proto if (self.service is not None) and (not self.service.startswith('_')): self.service = '_' + self.service if (self.type == 'TLSA'): if (self.proto is not None) and (not self.proto.startswith('_')): self.proto = '_' + self.proto if (self.port is not None): self.port = '_' + str(self.port) if not self.record.endswith(self.zone): self.record = self.record + '.' + self.zone if (self.type == 'DS'): if self.record == self.zone: self.module.fail_json(msg="DS records only apply to subdomains.") def _cf_simple_api_call(self, api_call, method='GET', payload=None): if self.api_token: headers = { 'Authorization': 'Bearer ' + self.api_token, 'Content-Type': 'application/json', } else: headers = { 'X-Auth-Email': self.account_email, 'X-Auth-Key': self.account_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.cf_api_endpoint + api_call, headers=headers, data=data, method=method, timeout=self.timeout) if info['status'] not in [200, 304, 400, 401, 403, 429, 405, 415]: self.module.fail_json(msg="Failed API call {0}; got unexpected HTTP code {1}: {2}".format(api_call, info['status'], info.get('msg'))) error_msg = '' if info['status'] == 401: # Unauthorized error_msg = "API user does not have permission; Status: {0}; Method: {1}: Call: {2}".format(info['status'], method, api_call) elif info['status'] == 403: # Forbidden error_msg = "API request not authenticated; Status: {0}; Method: {1}: Call: {2}".format(info['status'], method, api_call) elif info['status'] == 429: # Too many requests error_msg = "API client is rate limited; Status: {0}; Method: {1}: Call: {2}".format(info['status'], method, api_call) elif info['status'] == 405: # Method not allowed error_msg = "API incorrect HTTP method provided; Status: {0}; Method: {1}: Call: {2}".format(info['status'], method, api_call) elif info['status'] == 415: # Unsupported Media Type error_msg = "API request is not valid JSON; Status: {0}; Method: {1}: Call: {2}".format(info['status'], method, api_call) elif info['status'] == 400: # Bad Request error_msg = "API bad request; Status: {0}; Method: {1}: Call: {2}".format(info['status'], method, api_call) result = None try: content = resp.read() except AttributeError: if info['body']: content = info['body'] else: error_msg += "; The API response was empty" 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) # Without a valid/parsed JSON response no more error processing can be done if result is None: self.module.fail_json(msg=error_msg) if 'success' not in result: error_msg += "; Unexpected error details: {0}".format(result.get('error')) self.module.fail_json(msg=error_msg) if not result['success']: error_msg += "; Error details: " for error in result['errors']: error_msg += "code: {0}, error: {1}; ".format(error['code'], error['message']) if 'error_chain' in error: for chain_error in error['error_chain']: error_msg += "code: {0}, error: {1}; ".format(chain_error['code'], chain_error['message']) self.module.fail_json(msg=error_msg) return result, info['status'] def _cf_api_call(self, api_call, method='GET', payload=None): result, status = self._cf_simple_api_call(api_call, method, payload) data = result['result'] if 'result_info' in result: pagination = result['result_info'] if pagination['total_pages'] > 1: next_page = int(pagination['page']) + 1 parameters = ['page={0}'.format(next_page)] # strip "page" parameter from call parameters (if there are any) if '?' in api_call: raw_api_call, query = api_call.split('?', 1) parameters += [param for param in query.split('&') if not param.startswith('page')] else: raw_api_call = api_call while next_page <= pagination['total_pages']: raw_api_call += '?' + '&'.join(parameters) result, status = self._cf_simple_api_call(raw_api_call, method, payload) data += result['result'] next_page += 1 return data, status def _get_zone_id(self, zone=None): if not zone: zone = self.zone zones = self.get_zones(zone) if len(zones) > 1: self.module.fail_json(msg="More than one zone matches {0}".format(zone)) if len(zones) < 1: self.module.fail_json(msg="No zone found with name {0}".format(zone)) return zones[0]['id'] def get_zones(self, name=None): if not name: name = self.zone param = '' if name: param = '?' + urlencode({'name': name}) zones, status = self._cf_api_call('/zones' + param) return zones def get_dns_records(self, zone_name=None, type=None, record=None, value=''): if not zone_name: zone_name = self.zone if not type: type = self.type if not record: record = self.record # necessary because None as value means to override user # set module value if (not value) and (value is not None): value = self.value zone_id = self._get_zone_id() api_call = '/zones/{0}/dns_records'.format(zone_id) query = {} if type: query['type'] = type if record: query['name'] = record if value: query['content'] = value if query: api_call += '?' + urlencode(query) records, status = self._cf_api_call(api_call) return records def delete_dns_records(self, **kwargs): params = {} for param in ['port', 'proto', 'service', 'solo', 'type', 'record', 'value', 'weight', 'zone', 'algorithm', 'cert_usage', 'hash_type', 'selector', 'key_tag']: if param in kwargs: params[param] = kwargs[param] else: params[param] = getattr(self, param) records = [] content = params['value'] search_record = params['record'] if params['type'] == 'SRV': if not (params['value'] is None or params['value'] == ''): content = str(params['weight']) + '\t' + str(params['port']) + '\t' + params['value'] search_record = params['service'] + '.' + params['proto'] + '.' + params['record'] elif params['type'] == 'DS': if not (params['value'] is None or params['value'] == ''): content = str(params['key_tag']) + '\t' + str(params['algorithm']) + '\t' + str(params['hash_type']) + '\t' + params['value'] elif params['type'] == 'SSHFP': if not (params['value'] is None or params['value'] == ''): content = str(params['algorithm']) + '\t' + str(params['hash_type']) + '\t' + params['value'] elif params['type'] == 'TLSA': if not (params['value'] is None or params['value'] == ''): content = str(params['cert_usage']) + '\t' + str(params['selector']) + '\t' + str(params['hash_type']) + '\t' + params['value'] search_record = params['port'] + '.' + params['proto'] + '.' + params['record'] if params['solo']: search_value = None else: search_value = content records = self.get_dns_records(params['zone'], params['type'], search_record, search_value) for rr in records: if params['solo']: if not ((rr['type'] == params['type']) and (rr['name'] == search_record) and (rr['content'] == content)): self.changed = True if not self.module.check_mode: result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(rr['zone_id'], rr['id']), 'DELETE') else: self.changed = True if not self.module.check_mode: result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(rr['zone_id'], rr['id']), 'DELETE') return self.changed def ensure_dns_record(self, **kwargs): params = {} for param in ['port', 'priority', 'proto', 'proxied', 'service', 'ttl', 'type', 'record', 'value', 'weight', 'zone', 'algorithm', 'cert_usage', 'hash_type', 'selector', 'key_tag']: if param in kwargs: params[param] = kwargs[param] else: params[param] = getattr(self, param) search_value = params['value'] search_record = params['record'] new_record = None if (params['type'] is None) or (params['record'] is None): self.module.fail_json(msg="You must provide a type and a record to create a new record") if (params['type'] in ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'SPF']): if not params['value']: self.module.fail_json(msg="You must provide a non-empty value to create this record type") # there can only be one CNAME per record # ignoring the value when searching for existing # CNAME records allows us to update the value if it # changes if params['type'] == 'CNAME': search_value = None new_record = { "type": params['type'], "name": params['record'], "content": params['value'], "ttl": params['ttl'] } if (params['type'] in ['A', 'AAAA', 'CNAME']): new_record["proxied"] = params["proxied"] if params['type'] == 'MX': for attr in [params['priority'], params['value']]: if (attr is None) or (attr == ''): self.module.fail_json(msg="You must provide priority and a value to create this record type") new_record = { "type": params['type'], "name": params['record'], "content": params['value'], "priority": params['priority'], "ttl": params['ttl'] } if params['type'] == 'SRV': for attr in [params['port'], params['priority'], params['proto'], params['service'], params['weight'], params['value']]: if (attr is None) or (attr == ''): self.module.fail_json(msg="You must provide port, priority, proto, service, weight and a value to create this record type") srv_data = { "target": params['value'], "port": params['port'], "weight": params['weight'], "priority": params['priority'], "name": params['record'][:-len('.' + params['zone'])], "proto": params['proto'], "service": params['service'] } new_record = {"type": params['type'], "ttl": params['ttl'], 'data': srv_data} search_value = str(params['weight']) + '\t' + str(params['port']) + '\t' + params['value'] search_record = params['service'] + '.' + params['proto'] + '.' + params['record'] if params['type'] == 'DS': for attr in [params['key_tag'], params['algorithm'], params['hash_type'], params['value']]: if (attr is None) or (attr == ''): self.module.fail_json(msg="You must provide key_tag, algorithm, hash_type and a value to create this record type") ds_data = { "key_tag": params['key_tag'], "algorithm": params['algorithm'], "digest_type": params['hash_type'], "digest": params['value'], } new_record = { "type": params['type'], "name": params['record'], 'data': ds_data, "ttl": params['ttl'], } search_value = str(params['key_tag']) + '\t' + str(params['algorithm']) + '\t' + str(params['hash_type']) + '\t' + params['value'] if params['type'] == 'SSHFP': for attr in [params['algorithm'], params['hash_type'], params['value']]: if (attr is None) or (attr == ''): self.module.fail_json(msg="You must provide algorithm, hash_type and a value to create this record type") sshfp_data = { "fingerprint": params['value'], "type": params['hash_type'], "algorithm": params['algorithm'], } new_record = { "type": params['type'], "name": params['record'], 'data': sshfp_data, "ttl": params['ttl'], } search_value = str(params['algorithm']) + '\t' + str(params['hash_type']) + '\t' + params['value'] if params['type'] == 'TLSA': for attr in [params['port'], params['proto'], params['cert_usage'], params['selector'], params['hash_type'], params['value']]: if (attr is None) or (attr == ''): self.module.fail_json(msg="You must provide port, proto, cert_usage, selector, hash_type and a value to create this record type") search_record = params['port'] + '.' + params['proto'] + '.' + params['record'] tlsa_data = { "usage": params['cert_usage'], "selector": params['selector'], "matching_type": params['hash_type'], "certificate": params['value'], } new_record = { "type": params['type'], "name": search_record, 'data': tlsa_data, "ttl": params['ttl'], } search_value = str(params['cert_usage']) + '\t' + str(params['selector']) + '\t' + str(params['hash_type']) + '\t' + params['value'] zone_id = self._get_zone_id(params['zone']) records = self.get_dns_records(params['zone'], params['type'], search_record, search_value) # in theory this should be impossible as cloudflare does not allow # the creation of duplicate records but lets cover it anyways if len(records) > 1: self.module.fail_json(msg="More than one record already exists for the given attributes. That should be impossible, please open an issue!") # record already exists, check if it must be updated if len(records) == 1: cur_record = records[0] do_update = False if (params['ttl'] is not None) and (cur_record['ttl'] != params['ttl']): do_update = True if (params['priority'] is not None) and ('priority' in cur_record) and (cur_record['priority'] != params['priority']): do_update = True if ('proxied' in new_record) and ('proxied' in cur_record) and (cur_record['proxied'] != params['proxied']): do_update = True if ('data' in new_record) and ('data' in cur_record): if (cur_record['data'] != new_record['data']): do_update = True if (params['type'] == 'CNAME') and (cur_record['content'] != new_record['content']): do_update = True if do_update: if self.module.check_mode: result = new_record else: result, info = self._cf_api_call('/zones/{0}/dns_records/{1}'.format(zone_id, records[0]['id']), 'PUT', new_record) self.changed = True return result, self.changed else: return records, self.changed if self.module.check_mode: result = new_record else: result, info = self._cf_api_call('/zones/{0}/dns_records'.format(zone_id), 'POST', new_record) self.changed = True return result, self.changed def main(): module = AnsibleModule( argument_spec=dict( api_token=dict( type="str", required=False, no_log=True, fallback=(env_fallback, ["CLOUDFLARE_TOKEN"]), ), account_api_key=dict(type='str', required=False, no_log=True, aliases=['account_api_token']), account_email=dict(type='str', required=False), algorithm=dict(type='int'), cert_usage=dict(type='int', choices=[0, 1, 2, 3]), hash_type=dict(type='int', choices=[1, 2]), key_tag=dict(type='int', no_log=False), port=dict(type='int'), priority=dict(type='int', default=1), proto=dict(type='str'), proxied=dict(type='bool', default=False), record=dict(type='str', default='@', aliases=['name']), selector=dict(type='int', choices=[0, 1]), service=dict(type='str'), solo=dict(type='bool'), state=dict(type='str', default='present', choices=['absent', 'present']), timeout=dict(type='int', default=30), ttl=dict(type='int', default=1), type=dict(type='str', choices=['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'SPF', 'SRV', 'SSHFP', 'TLSA', 'TXT']), value=dict(type='str', aliases=['content']), weight=dict(type='int', default=1), zone=dict(type='str', required=True, aliases=['domain']), ), supports_check_mode=True, required_if=[ ('state', 'present', ['record', 'type', 'value']), ('state', 'absent', ['record']), ('type', 'SRV', ['proto', 'service']), ('type', 'TLSA', ['proto', 'port']), ], ) if not module.params['api_token'] and not (module.params['account_api_key'] and module.params['account_email']): module.fail_json(msg="Either api_token or account_api_key and account_email params are required.") if module.params['type'] == 'SRV': if not ((module.params['weight'] is not None and module.params['port'] is not None and not (module.params['value'] is None or module.params['value'] == '')) or (module.params['weight'] is None and module.params['port'] is None and (module.params['value'] is None or module.params['value'] == ''))): module.fail_json(msg="For SRV records the params weight, port and value all need to be defined, or not at all.") if module.params['type'] == 'SSHFP': if not ((module.params['algorithm'] is not None and module.params['hash_type'] is not None and not (module.params['value'] is None or module.params['value'] == '')) or (module.params['algorithm'] is None and module.params['hash_type'] is None and (module.params['value'] is None or module.params['value'] == ''))): module.fail_json(msg="For SSHFP records the params algorithm, hash_type and value all need to be defined, or not at all.") if module.params['type'] == 'TLSA': if not ((module.params['cert_usage'] is not None and module.params['selector'] is not None and module.params['hash_type'] is not None and not (module.params['value'] is None or module.params['value'] == '')) or (module.params['cert_usage'] is None and module.params['selector'] is None and module.params['hash_type'] is None and (module.params['value'] is None or module.params['value'] == ''))): module.fail_json(msg="For TLSA records the params cert_usage, selector, hash_type and value all need to be defined, or not at all.") if module.params['type'] == 'DS': if not ((module.params['key_tag'] is not None and module.params['algorithm'] is not None and module.params['hash_type'] is not None and not (module.params['value'] is None or module.params['value'] == '')) or (module.params['key_tag'] is None and module.params['algorithm'] is None and module.params['hash_type'] is None and (module.params['value'] is None or module.params['value'] == ''))): module.fail_json(msg="For DS records the params key_tag, algorithm, hash_type and value all need to be defined, or not at all.") changed = False cf_api = CloudflareAPI(module) # sanity checks if cf_api.is_solo and cf_api.state == 'absent': module.fail_json(msg="solo=true can only be used with state=present") # perform add, delete or update (only the TTL can be updated) of one or # more records if cf_api.state == 'present': # delete all records matching record name + type if cf_api.is_solo: changed = cf_api.delete_dns_records(solo=cf_api.is_solo) result, changed = cf_api.ensure_dns_record() if isinstance(result, list): module.exit_json(changed=changed, result={'record': result[0]}) module.exit_json(changed=changed, result={'record': result}) else: # force solo to False, just to be sure changed = cf_api.delete_dns_records(solo=False) module.exit_json(changed=changed) if __name__ == '__main__': main()