You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ansible/cloud/google/gcdns_record.py

791 lines
27 KiB
Python

#!/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 <http://www.gnu.org/licenses/>.
################################################################################
# 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'] = '<absent>'
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'] = '<absent>'
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()