#!/usr/bin/python # -*- coding: utf-8 -*- # # Copyright (C) 2015 CallFire Inc. # # This file is part of Ansible. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . ################################################################################ # Documentation ################################################################################ DOCUMENTATION = ''' --- module: gcdns_record short_description: Creates or removes resource records in Google Cloud DNS description: - Creates or removes resource records in Google Cloud DNS. version_added: "2.2" author: "William Albert (@walbert947)" requirements: - "python >= 2.6" - "apache-libcloud >= 0.19.0" options: state: description: - Whether the given resource record should or should not be present. required: false choices: ["present", "absent"] default: "present" record: description: - The fully-qualified domain name of the resource record. required: true aliases: ['name'] zone: description: - The DNS domain name of the zone (e.g., example.com). - One of either I(zone) or I(zone_id) must be specified as an option, or the module will fail. - If both I(zone) and I(zone_id) are specifed, I(zone_id) will be used. required: false zone_id: description: - The Google Cloud ID of the zone (e.g., example-com). - One of either I(zone) or I(zone_id) must be specified as an option, or the module will fail. - These usually take the form of domain names with the dots replaced with dashes. A zone ID will never have any dots in it. - I(zone_id) can be faster than I(zone) in projects with a large number of zones. - If both I(zone) and I(zone_id) are specifed, I(zone_id) will be used. required: false type: description: - The type of resource record to add. required: true choices: [ 'A', 'AAAA', 'CNAME', 'SRV', 'TXT', 'SOA', 'NS', 'MX', 'SPF', 'PTR' ] values: description: - The values to use for the resource record. - I(values) must be specified if I(state) is C(present) or I(overwrite) is C(True), or the module will fail. - Valid values vary based on the record's I(type). In addition, resource records that contain a DNS domain name in the value field (e.g., CNAME, PTR, SRV, .etc) MUST include a trailing dot in the value. - Individual string values for TXT records must be enclosed in double quotes. - For resource records that have the same name but different values (e.g., multiple A records), they must be defined as multiple list entries in a single record. required: false aliases: ['value'] ttl: description: - The amount of time in seconds that a resource record will remain cached by a caching resolver. required: false default: 300 overwrite: description: - Whether an attempt to overwrite an existing record should succeed or fail. The behavior of this option depends on I(state). - If I(state) is C(present) and I(overwrite) is C(True), this module will replace an existing resource record of the same name with the provided I(values). If I(state) is C(present) and I(overwrite) is C(False), this module will fail if there is an existing resource record with the same name and type, but different resource data. - If I(state) is C(absent) and I(overwrite) is C(True), this module will remove the given resource record unconditionally. If I(state) is C(absent) and I(overwrite) is C(False), this module will fail if the provided values do not match exactly with the existing resource record's values. required: false choices: [True, False] default: False service_account_email: description: - The e-mail address for a service account with access to Google Cloud DNS. required: false default: null pem_file: description: - The path to the PEM file associated with the service account email. - This option is deprecated and may be removed in a future release. Use I(credentials_file) instead. required: false default: null credentials_file: description: - The path to the JSON file associated with the service account email. required: false default: null project_id: description: - The Google Cloud Platform project ID to use. required: false default: null notes: - See also M(gcdns_zone). - This modules's underlying library does not support in-place updates for DNS resource records. Instead, resource records are quickly deleted and recreated. - SOA records are technically supported, but their functionality is limited to verifying that a zone's existing SOA record matches a pre-determined value. The SOA record cannot be updated. - Root NS records cannot be updated. - NAPTR records are not supported. ''' EXAMPLES = ''' # Create an A record. - gcdns_record: record: 'www1.example.com' zone: 'example.com' type: A value: '1.2.3.4' # Update an existing record. - gcdns_record: record: 'www1.example.com' zone: 'example.com' type: A overwrite: true value: '5.6.7.8' # Remove an A record. - gcdns_record: record: 'www1.example.com' zone_id: 'example-com' state: absent type: A value: '5.6.7.8' # Create a CNAME record. - gcdns_record: record: 'www.example.com' zone_id: 'example-com' type: CNAME value: 'www.example.com.' # Note the trailing dot # Create an MX record with a custom TTL. - gcdns_record: record: 'example.com' zone: 'example.com' type: MX ttl: 3600 value: '10 mail.example.com.' # Note the trailing dot # Create multiple A records with the same name. - gcdns_record: record: 'api.example.com' zone_id: 'example-com' type: A values: - '10.1.2.3' - '10.4.5.6' - '10.7.8.9' - '192.168.5.10' # Change the value of an existing record with multiple values. - gcdns_record: record: 'api.example.com' zone: 'example.com' type: A overwrite: true values: # WARNING: All values in a record will be replaced - '10.1.2.3' - '10.5.5.7' # The changed record - '10.7.8.9' - '192.168.5.10' # Safely remove a multi-line record. - gcdns_record: record: 'api.example.com' zone_id: 'example-com' state: absent type: A values: # NOTE: All of the values must match exactly - '10.1.2.3' - '10.5.5.7' - '10.7.8.9' - '192.168.5.10' # Unconditionally remove a record. - gcdns_record: record: 'api.example.com' zone_id: 'example-com' state: absent overwrite: true # overwrite is true, so no values are needed type: A # Create an AAAA record - gcdns_record: record: 'www1.example.com' zone: 'example.com' type: AAAA value: 'fd00:db8::1' # Create a PTR record - gcdns_record: record: '10.5.168.192.in-addr.arpa' zone: '5.168.192.in-addr.arpa' type: PTR value: 'api.example.com.' # Note the trailing dot. # Create an NS record - gcdns_record: record: 'subdomain.example.com' zone: 'example.com' type: NS ttl: 21600 values: - 'ns-cloud-d1.googledomains.com.' # Note the trailing dots on values - 'ns-cloud-d2.googledomains.com.' - 'ns-cloud-d3.googledomains.com.' - 'ns-cloud-d4.googledomains.com.' # Create a TXT record - gcdns_record: record: 'example.com' zone_id: 'example-com' type: TXT values: - '"v=spf1 include:_spf.google.com -all"' # A single-string TXT value - '"hello " "world"' # A multi-string TXT value ''' RETURN = ''' overwrite: description: Whether to the module was allowed to overwrite the record returned: success type: boolean sample: True record: description: Fully-qualified domain name of the resource record returned: success type: string sample: mail.example.com. state: description: Whether the record is present or absent returned: success type: string sample: present ttl: description: The time-to-live of the resource record returned: success type: int sample: 300 type: description: The type of the resource record returned: success type: string sample: A values: description: The resource record values returned: success type: list sample: ['5.6.7.8', '9.10.11.12'] zone: description: The dns name of the zone returned: success type: string sample: example.com. zone_id: description: The Google Cloud DNS ID of the zone returned: success type: string sample: example-com ''' ################################################################################ # Imports ################################################################################ import socket from distutils.version import LooseVersion try: from libcloud import __version__ as LIBCLOUD_VERSION from libcloud.common.google import InvalidRequestError from libcloud.common.types import LibcloudError from libcloud.dns.types import Provider from libcloud.dns.types import RecordDoesNotExistError from libcloud.dns.types import ZoneDoesNotExistError HAS_LIBCLOUD = True except ImportError: HAS_LIBCLOUD = False ################################################################################ # Constants ################################################################################ # Apache libcloud 0.19.0 was the first to contain the non-beta Google Cloud DNS # v1 API. Earlier versions contained the beta v1 API, which has since been # deprecated and decommissioned. MINIMUM_LIBCLOUD_VERSION = '0.19.0' # The libcloud Google Cloud DNS provider. PROVIDER = Provider.GOOGLE # The records that libcloud's Google Cloud DNS provider supports. # # Libcloud has a RECORD_TYPE_MAP dictionary in the provider that also contains # this information and is the authoritative source on which records are # supported, but accessing the dictionary requires creating a Google Cloud DNS # driver object, which is done in a helper module. # # I'm hard-coding the supported record types here, because they (hopefully!) # shouldn't change much, and it allows me to use it as a "choices" parameter # in an AnsibleModule argument_spec. SUPPORTED_RECORD_TYPES = [ 'A', 'AAAA', 'CNAME', 'SRV', 'TXT', 'SOA', 'NS', 'MX', 'SPF', 'PTR' ] ################################################################################ # Functions ################################################################################ def create_record(module, gcdns, zone, record): """Creates or overwrites a resource record.""" overwrite = module.boolean(module.params['overwrite']) record_name = module.params['record'] record_type = module.params['type'] ttl = module.params['ttl'] values = module.params['values'] data = dict(ttl=ttl, rrdatas=values) # Google Cloud DNS wants the trailing dot on all DNS names. if record_name[-1] != '.': record_name = record_name + '.' # If we found a record, we need to check if the values match. if record is not None: # If the record matches, we obviously don't have to change anything. if _records_match(record.data['ttl'], record.data['rrdatas'], ttl, values): return False # The record doesn't match, so we need to check if we can overwrite it. if not overwrite: module.fail_json( msg = 'cannot overwrite existing record, overwrite protection enabled', changed = False ) # The record either doesn't exist, or it exists and we can overwrite it. if record is None and not module.check_mode: # There's no existing record, so we'll just create it. try: gcdns.create_record(record_name, zone, record_type, data) except InvalidRequestError as error: if error.code == 'invalid': # The resource record name and type are valid by themselves, but # not when combined (e.g., an 'A' record with "www.example.com" # as its value). module.fail_json( msg = 'value is invalid for the given type: ' + "%s, got value: %s" % (record_type, values), changed = False ) elif error.code == 'cnameResourceRecordSetConflict': # We're attempting to create a CNAME resource record when we # already have another type of resource record with the name # domain name. module.fail_json( msg = "non-CNAME resource record already exists: %s" % record_name, changed = False ) else: # The error is something else that we don't know how to handle, # so we'll just re-raise the exception. raise elif record is not None and not module.check_mode: # The Google provider in libcloud doesn't support updating a record in # place, so if the record already exists, we need to delete it and # recreate it using the new information. gcdns.delete_record(record) try: gcdns.create_record(record_name, zone, record_type, data) except InvalidRequestError: # Something blew up when creating the record. This will usually be a # result of invalid value data in the new record. Unfortunately, we # already changed the state of the record by deleting the old one, # so we'll try to roll back before failing out. try: gcdns.create_record(record.name, record.zone, record.type, record.data) module.fail_json( msg = 'error updating record, the original record was restored', changed = False ) except LibcloudError: # We deleted the old record, couldn't create the new record, and # couldn't roll back. That really sucks. We'll dump the original # record to the failure output so the user can resore it if # necessary. module.fail_json( msg = 'error updating record, and could not restore original record, ' + "original name: %s " % record.name + "original zone: %s " % record.zone + "original type: %s " % record.type + "original data: %s" % record.data, changed = True) return True def remove_record(module, gcdns, record): """Remove a resource record.""" overwrite = module.boolean(module.params['overwrite']) ttl = module.params['ttl'] values = module.params['values'] # If there is no record, we're obviously done. if record is None: return False # If there is an existing record, do our values match the values of the # existing record? if not overwrite: if not _records_match(record.data['ttl'], record.data['rrdatas'], ttl, values): module.fail_json( msg = 'cannot delete due to non-matching ttl or values: ' + "ttl: %d, values: %s " % (ttl, values) + "original ttl: %d, original values: %s" % (record.data['ttl'], record.data['rrdatas']), changed = False ) # If we got to this point, we're okay to delete the record. if not module.check_mode: gcdns.delete_record(record) return True def _get_record(gcdns, zone, record_type, record_name): """Gets the record object for a given FQDN.""" # The record ID is a combination of its type and FQDN. For example, the # ID of an A record for www.example.com would be 'A:www.example.com.' record_id = "%s:%s" % (record_type, record_name) try: return gcdns.get_record(zone.id, record_id) except RecordDoesNotExistError: return None def _get_zone(gcdns, zone_name, zone_id): """Gets the zone object for a given domain name.""" if zone_id is not None: try: return gcdns.get_zone(zone_id) except ZoneDoesNotExistError: return None # To create a zone, we need to supply a domain name. However, to delete a # zone, we need to supply a zone ID. Zone ID's are often based on domain # names, but that's not guaranteed, so we'll iterate through the list of # zones to see if we can find a matching domain name. available_zones = gcdns.iterate_zones() found_zone = None for zone in available_zones: if zone.domain == zone_name: found_zone = zone break return found_zone def _records_match(old_ttl, old_values, new_ttl, new_values): """Checks to see if original and new TTL and values match.""" matches = True if old_ttl != new_ttl: matches = False if old_values != new_values: matches = False return matches def _sanity_check(module): """Run sanity checks that don't depend on info from the zone/record.""" overwrite = module.params['overwrite'] record_name = module.params['record'] record_type = module.params['type'] state = module.params['state'] ttl = module.params['ttl'] values = module.params['values'] # Apache libcloud needs to be installed and at least the minimum version. if not HAS_LIBCLOUD: module.fail_json( msg = 'This module requires Apache libcloud %s or greater' % MINIMUM_LIBCLOUD_VERSION, changed = False ) elif LooseVersion(LIBCLOUD_VERSION) < MINIMUM_LIBCLOUD_VERSION: module.fail_json( msg = 'This module requires Apache libcloud %s or greater' % MINIMUM_LIBCLOUD_VERSION, changed = False ) # A negative TTL is not permitted (how would they even work?!). if ttl < 0: module.fail_json( msg = 'TTL cannot be less than zero, got: %d' % ttl, changed = False ) # Deleting SOA records is not permitted. if record_type == 'SOA' and state == 'absent': module.fail_json(msg='cannot delete SOA records', changed=False) # Updating SOA records is not permitted. if record_type == 'SOA' and state == 'present' and overwrite: module.fail_json(msg='cannot update SOA records', changed=False) # Some sanity checks depend on what value was supplied. if values is not None and (state == 'present' or not overwrite): # A records must contain valid IPv4 addresses. if record_type == 'A': for value in values: try: socket.inet_aton(value) except socket.error: module.fail_json( msg = 'invalid A record value, got: %s' % value, changed = False ) # AAAA records must contain valid IPv6 addresses. if record_type == 'AAAA': for value in values: try: socket.inet_pton(socket.AF_INET6, value) except socket.error: module.fail_json( msg = 'invalid AAAA record value, got: %s' % value, changed = False ) # CNAME and SOA records can't have multiple values. if record_type in ['CNAME', 'SOA'] and len(values) > 1: module.fail_json( msg = 'CNAME or SOA records cannot have more than one value, ' + "got: %s" % values, changed = False ) # Google Cloud DNS does not support wildcard NS records. if record_type == 'NS' and record_name[0] == '*': module.fail_json( msg = "wildcard NS records not allowed, got: %s" % record_name, changed = False ) # Values for txt records must begin and end with a double quote. if record_type == 'TXT': for value in values: if value[0] != '"' and value[-1] != '"': module.fail_json( msg = 'TXT values must be enclosed in double quotes, ' + 'got: %s' % value, changed = False ) def _additional_sanity_checks(module, zone): """Run input sanity checks that depend on info from the zone/record.""" overwrite = module.params['overwrite'] record_name = module.params['record'] record_type = module.params['type'] state = module.params['state'] # CNAME records are not allowed to have the same name as the root domain. if record_type == 'CNAME' and record_name == zone.domain: module.fail_json( msg = 'CNAME records cannot match the zone name', changed = False ) # The root domain must always have an NS record. if record_type == 'NS' and record_name == zone.domain and state == 'absent': module.fail_json( msg = 'cannot delete root NS records', changed = False ) # Updating NS records with the name as the root domain is not allowed # because libcloud does not support in-place updates and root domain NS # records cannot be removed. if record_type == 'NS' and record_name == zone.domain and overwrite: module.fail_json( msg = 'cannot update existing root NS records', changed = False ) # SOA records with names that don't match the root domain are not permitted # (and wouldn't make sense anyway). if record_type == 'SOA' and record_name != zone.domain: module.fail_json( msg = 'non-root SOA records are not permitted, got: %s' % record_name, changed = False ) ################################################################################ # Main ################################################################################ def main(): """Main function""" module = AnsibleModule( argument_spec = dict( state = dict(default='present', choices=['present', 'absent'], type='str'), record = dict(required=True, aliases=['name'], type='str'), zone = dict(type='str'), zone_id = dict(type='str'), type = dict(required=True, choices=SUPPORTED_RECORD_TYPES, type='str'), values = dict(aliases=['value'], type='list'), ttl = dict(default=300, type='int'), overwrite = dict(default=False, type='bool'), service_account_email = dict(type='str'), pem_file = dict(type='path'), credentials_file = dict(type='path'), project_id = dict(type='str') ), required_if = [ ('state', 'present', ['values']), ('overwrite', False, ['values']) ], required_one_of = [['zone', 'zone_id']], supports_check_mode = True ) _sanity_check(module) record_name = module.params['record'] record_type = module.params['type'] state = module.params['state'] ttl = module.params['ttl'] zone_name = module.params['zone'] zone_id = module.params['zone_id'] json_output = dict( state = state, record = record_name, zone = zone_name, zone_id = zone_id, type = record_type, values = module.params['values'], ttl = ttl, overwrite = module.boolean(module.params['overwrite']) ) # Google Cloud DNS wants the trailing dot on all DNS names. if zone_name is not None and zone_name[-1] != '.': zone_name = zone_name + '.' if record_name[-1] != '.': record_name = record_name + '.' # Build a connection object that we can use to connect with Google Cloud # DNS. gcdns = gcdns_connect(module, provider=PROVIDER) # We need to check that the zone we're creating a record for actually # exists. zone = _get_zone(gcdns, zone_name, zone_id) if zone is None and zone_name is not None: module.fail_json( msg = 'zone name was not found: %s' % zone_name, changed = False ) elif zone is None and zone_id is not None: module.fail_json( msg = 'zone id was not found: %s' % zone_id, changed = False ) # Populate the returns with the actual zone information. json_output['zone'] = zone.domain json_output['zone_id'] = zone.id # We also need to check if the record we want to create or remove actually # exists. try: record = _get_record(gcdns, zone, record_type, record_name) except InvalidRequestError: # We gave Google Cloud DNS an invalid DNS record name. module.fail_json( msg = 'record name is invalid: %s' % record_name, changed = False ) _additional_sanity_checks(module, zone) diff = dict() # Build the 'before' diff if record is None: diff['before'] = '' diff['before_header'] = '' else: diff['before'] = dict( record = record.data['name'], type = record.data['type'], values = record.data['rrdatas'], ttl = record.data['ttl'] ) diff['before_header'] = "%s:%s" % (record_type, record_name) # Create, remove, or modify the record. if state == 'present': diff['after'] = dict( record = record_name, type = record_type, values = module.params['values'], ttl = ttl ) diff['after_header'] = "%s:%s" % (record_type, record_name) changed = create_record(module, gcdns, zone, record) elif state == 'absent': diff['after'] = '' diff['after_header'] = '' changed = remove_record(module, gcdns, record) module.exit_json(changed=changed, diff=diff, **json_output) from ansible.module_utils.basic import * from ansible.module_utils.gcdns import * if __name__ == '__main__': main()