From 9fda16070fcff4e49fad0b222c00b4eb95238e72 Mon Sep 17 00:00:00 2001 From: William Albert Date: Thu, 28 Jul 2016 09:56:35 -0700 Subject: [PATCH] Add modules to support Google Cloud DNS (#2252) This commit adds modules that can manipulate Google Cloud DNS. The modules can create and delete zones, as well as records within zones. --- cloud/google/gcdns_record.py | 790 +++++++++++++++++++++++++++++++++++ cloud/google/gcdns_zone.py | 381 +++++++++++++++++ 2 files changed, 1171 insertions(+) create mode 100644 cloud/google/gcdns_record.py create mode 100644 cloud/google/gcdns_zone.py diff --git a/cloud/google/gcdns_record.py b/cloud/google/gcdns_record.py new file mode 100644 index 00000000000..ebccfca5dcb --- /dev/null +++ b/cloud/google/gcdns_record.py @@ -0,0 +1,790 @@ +#!/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() diff --git a/cloud/google/gcdns_zone.py b/cloud/google/gcdns_zone.py new file mode 100644 index 00000000000..4b7bd16985b --- /dev/null +++ b/cloud/google/gcdns_zone.py @@ -0,0 +1,381 @@ +#!/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_zone +short_description: Creates or removes zones in Google Cloud DNS +description: + - Creates or removes managed zones 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 zone should or should not be present. + required: false + choices: ["present", "absent"] + default: "present" + zone: + description: + - The DNS domain name of the zone. + - This is NOT the Google Cloud DNS zone ID (e.g., example-com). If + you attempt to specify a zone ID, this module will attempt to + create a TLD and will fail. + required: true + aliases: ['name'] + description: + description: + - An arbitrary text string to use for the zone description. + required: false + default: "" + 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_record). + - Zones that are newly created must still be set up with a domain registrar + before they can be used. +''' + +EXAMPLES = ''' +# Basic zone creation example. +- name: Create a basic zone with the minimum number of parameters. + gcdns_zone: zone=example.com + +# Zone removal example. +- name: Remove a zone. + gcdns_zone: zone=example.com state=absent + +# Zone creation with description +- name: Creating a zone with a description + gcdns_zone: zone=example.com description="This is an awesome zone" +''' + +RETURN = ''' +description: + description: The zone's description + returned: success + type: string + sample: This is an awesome zone +state: + description: Whether the zone is present or absent + returned: success + type: string + sample: present +zone: + description: The zone's DNS name + returned: success + type: string + sample: example.com. +''' + + +################################################################################ +# Imports +################################################################################ + +from distutils.version import LooseVersion + +try: + from libcloud import __version__ as LIBCLOUD_VERSION + from libcloud.common.google import InvalidRequestError + from libcloud.common.google import ResourceExistsError + from libcloud.common.google import ResourceNotFoundError + from libcloud.dns.types import Provider + 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 URL used to verify ownership of a zone in Google Cloud DNS. +ZONE_VERIFICATION_URL= 'https://www.google.com/webmasters/verification/' + +################################################################################ +# Functions +################################################################################ + +def create_zone(module, gcdns, zone): + """Creates a new Google Cloud DNS zone.""" + + description = module.params['description'] + extra = dict(description = description) + zone_name = module.params['zone'] + + # Google Cloud DNS wants the trailing dot on the domain name. + if zone_name[-1] != '.': + zone_name = zone_name + '.' + + # If we got a zone back, then the domain exists. + if zone is not None: + return False + + # The zone doesn't exist yet. + try: + if not module.check_mode: + gcdns.create_zone(domain=zone_name, extra=extra) + return True + + except ResourceExistsError: + # The zone already exists. We checked for this already, so either + # Google is lying, or someone was a ninja and created the zone + # within milliseconds of us checking for its existence. In any case, + # the zone has already been created, so we have nothing more to do. + return False + + except InvalidRequestError as error: + if error.code == 'invalid': + # The zone name or a parameter might be completely invalid. This is + # typically caused by an illegal DNS name (e.g. foo..com). + module.fail_json( + msg = "zone name is not a valid DNS name: %s" % zone_name, + changed = False + ) + + elif error.code == 'managedZoneDnsNameNotAvailable': + # Google Cloud DNS will refuse to create zones with certain domain + # names, such as TLDs, ccTLDs, or special domain names such as + # example.com. + module.fail_json( + msg = "zone name is reserved or already in use: %s" % zone_name, + changed = False + ) + + elif error.code == 'verifyManagedZoneDnsNameOwnership': + # This domain name needs to be verified before Google will create + # it. This occurs when a user attempts to create a zone which shares + # a domain name with a zone hosted elsewhere in Google Cloud DNS. + module.fail_json( + msg = "ownership of zone %s needs to be verified at %s" % (zone_name, ZONE_VERIFICATION_URL), + 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 + + +def remove_zone(module, gcdns, zone): + """Removes an existing Google Cloud DNS zone.""" + + # If there's no zone, then we're obviously done. + if zone is None: + return False + + # An empty zone will have two resource records: + # 1. An NS record with a list of authoritative name servers + # 2. An SOA record + # If any additional resource records are present, Google Cloud DNS will + # refuse to remove the zone. + if len(zone.list_records()) > 2: + module.fail_json( + msg = "zone is not empty and cannot be removed: %s" % zone.domain, + changed = False + ) + + try: + if not module.check_mode: + gcdns.delete_zone(zone) + return True + + except ResourceNotFoundError: + # When we performed our check, the zone existed. It may have been + # deleted by something else. It's gone, so whatever. + return False + + except InvalidRequestError as error: + if error.code == 'containerNotEmpty': + # When we performed our check, the zone existed and was empty. In + # the milliseconds between the check and the removal command, + # records were added to the zone. + module.fail_json( + msg = "zone is not empty and cannot be removed: %s" % zone.domain, + 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 + + +def _get_zone(gcdns, zone_name): + """Gets the zone object for a given domain name.""" + + # To create a zone, we need to supply a zone name. However, to delete a + # zone, we need to supply a zone ID. Zone ID's are often based on zone + # names, but that's not guaranteed, so we'll iterate through the list of + # zones to see if we can find a matching 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 _sanity_check(module): + """Run module sanity checks.""" + + zone_name = module.params['zone'] + + # 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 + ) + + # Google Cloud DNS does not support the creation of TLDs. + if '.' not in zone_name or len([label for label in zone_name.split('.') if label]) == 1: + module.fail_json( + msg = 'cannot create top-level domain: %s' % zone_name, + changed = False + ) + +################################################################################ +# Main +################################################################################ + +def main(): + """Main function""" + + module = AnsibleModule( + argument_spec = dict( + state = dict(default='present', choices=['present', 'absent'], type='str'), + zone = dict(required=True, aliases=['name'], type='str'), + description = dict(default='', type='str'), + service_account_email = dict(type='str'), + pem_file = dict(type='path'), + credentials_file = dict(type='path'), + project_id = dict(type='str') + ), + supports_check_mode = True + ) + + _sanity_check(module) + + zone_name = module.params['zone'] + state = module.params['state'] + + # Google Cloud DNS wants the trailing dot on the domain name. + if zone_name[-1] != '.': + zone_name = zone_name + '.' + + json_output = dict( + state = state, + zone = zone_name, + description = module.params['description'] + ) + + # Build a connection object that was can use to connect with Google + # Cloud DNS. + gcdns = gcdns_connect(module, provider=PROVIDER) + + # We need to check if the zone we're attempting to create already exists. + zone = _get_zone(gcdns, zone_name) + + diff = dict() + + # Build the 'before' diff + if zone is None: + diff['before'] = '' + diff['before_header'] = '' + else: + diff['before'] = dict( + zone = zone.domain, + description = zone.extra['description'] + ) + diff['before_header'] = zone_name + + # Create or remove the zone. + if state == 'present': + diff['after'] = dict( + zone = zone_name, + description = module.params['description'] + ) + diff['after_header'] = zone_name + + changed = create_zone(module, gcdns, zone) + + elif state == 'absent': + diff['after'] = '' + diff['after_header'] = '' + + changed = remove_zone(module, gcdns, zone) + + 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()