Source code for libcloud.dns.drivers.rackspace

# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import copy

from libcloud.utils.py3 import httplib
from libcloud.common.openstack import OpenStackDriverMixin
from libcloud.common.base import PollingConnection
from libcloud.common.exceptions import BaseHTTPError
from libcloud.common.types import LibcloudError
from libcloud.utils.misc import merge_valid_keys, get_new_obj
from libcloud.common.rackspace import AUTH_URL
from libcloud.compute.drivers.openstack import OpenStack_1_1_Connection
from libcloud.compute.drivers.openstack import OpenStack_1_1_Response

from libcloud.dns.types import Provider, RecordType
from libcloud.dns.types import ZoneDoesNotExistError, RecordDoesNotExistError
from libcloud.dns.base import DNSDriver, Zone, Record

__all__ = [
    'RackspaceDNSResponse',
    'RackspaceDNSConnection'
]

VALID_ZONE_EXTRA_PARAMS = ['email', 'comment', 'ns1']
VALID_RECORD_EXTRA_PARAMS = ['ttl', 'comment', 'priority', 'created',
                             'updated']


[docs]class RackspaceDNSResponse(OpenStack_1_1_Response): """ Rackspace DNS Response class. """
[docs] def parse_error(self): status = int(self.status) context = self.connection.context body = self.parse_body() if status == httplib.NOT_FOUND: if context['resource'] == 'zone': raise ZoneDoesNotExistError(value='', driver=self, zone_id=context['id']) elif context['resource'] == 'record': raise RecordDoesNotExistError(value='', driver=self, record_id=context['id']) if body: if 'code' and 'message' in body: err = '%s - %s (%s)' % (body['code'], body['message'], body['details']) return err elif 'validationErrors' in body: errors = [m for m in body['validationErrors']['messages']] err = 'Validation errors: %s' % ', '.join(errors) return err raise LibcloudError('Unexpected status code: %s' % (status))
[docs]class RackspaceDNSConnection(OpenStack_1_1_Connection, PollingConnection): """ Rackspace DNS Connection class. """ responseCls = RackspaceDNSResponse XML_NAMESPACE = None poll_interval = 2.5 timeout = 30 auth_url = AUTH_URL _auth_version = '2.0' def __init__(self, *args, **kwargs): self.region = kwargs.pop('region', None) super(RackspaceDNSConnection, self).__init__(*args, **kwargs)
[docs] def get_poll_request_kwargs(self, response, context, request_kwargs): job_id = response.object['jobId'] kwargs = {'action': '/status/%s' % (job_id), 'params': {'showDetails': True}} return kwargs
[docs] def has_completed(self, response): status = response.object['status'] if status == 'ERROR': data = response.object['error'] if 'code' and 'message' in data: message = '%s - %s (%s)' % (data['code'], data['message'], data['details']) else: message = data['message'] raise LibcloudError(message, driver=self.driver) return status == 'COMPLETED'
[docs] def get_endpoint(self): if '2.0' in self._auth_version: ep = self.service_catalog.get_endpoint(name='cloudDNS', service_type='rax:dns', region=None) else: raise LibcloudError("Auth version %s not supported" % (self._auth_version)) public_url = ep.url # This is a nasty hack, but because of how global auth and old accounts # work, there is no way around it. if self.region == 'us': # Old UK account, which only has us endpoint in the catalog public_url = public_url.replace('https://lon.dns.api', 'https://dns.api') if self.region == 'uk': # Old US account, which only has uk endpoint in the catalog public_url = public_url.replace('https://dns.api', 'https://lon.dns.api') return public_url
class RackspacePTRRecord(object): def __init__(self, id, ip, domain, driver, extra=None): self.id = str(id) if id else None self.ip = ip self.type = RecordType.PTR self.domain = domain self.driver = driver self.extra = extra or {} def update(self, domain, extra=None): return self.driver.ex_update_ptr_record(record=self, domain=domain, extra=extra) def delete(self): return self.driver.ex_delete_ptr_record(record=self) def __repr__(self): return ('<%s: ip=%s, domain=%s, provider=%s ...>' % (self.__class__.__name__, self.ip, self.domain, self.driver.name)) class RackspaceDNSDriver(DNSDriver, OpenStackDriverMixin): name = 'Rackspace DNS' website = 'http://www.rackspace.com/' type = Provider.RACKSPACE connectionCls = RackspaceDNSConnection def __init__(self, key, secret=None, secure=True, host=None, port=None, region='us', **kwargs): valid_regions = self.list_regions() if region not in valid_regions: raise ValueError('Invalid region: %s' % (region)) OpenStackDriverMixin.__init__(self, **kwargs) super(RackspaceDNSDriver, self).__init__(key=key, secret=secret, host=host, port=port, region=region) RECORD_TYPE_MAP = { RecordType.A: 'A', RecordType.AAAA: 'AAAA', RecordType.CNAME: 'CNAME', RecordType.MX: 'MX', RecordType.NS: 'NS', RecordType.PTR: 'PTR', RecordType.SRV: 'SRV', RecordType.TXT: 'TXT', } @classmethod def list_regions(cls): return ['us', 'uk'] def iterate_zones(self): offset = 0 limit = 100 while True: params = { 'limit': limit, 'offset': offset, } response = self.connection.request( action='/domains', params=params).object zones_list = response['domains'] for item in zones_list: yield self._to_zone(item) if _rackspace_result_has_more(response, len(zones_list), limit): offset += limit else: break def iterate_records(self, zone): self.connection.set_context({'resource': 'zone', 'id': zone.id}) offset = 0 limit = 100 while True: params = { 'showRecord': True, 'limit': limit, 'offset': offset, } response = self.connection.request( action='/domains/%s' % (zone.id), params=params).object records_list = response['recordsList'] records = records_list['records'] for item in records: record = self._to_record(data=item, zone=zone) yield record if _rackspace_result_has_more(records_list, len(records), limit): offset += limit else: break def get_zone(self, zone_id): self.connection.set_context({'resource': 'zone', 'id': zone_id}) response = self.connection.request(action='/domains/%s' % (zone_id)) zone = self._to_zone(data=response.object) return zone def get_record(self, zone_id, record_id): zone = self.get_zone(zone_id=zone_id) self.connection.set_context({'resource': 'record', 'id': record_id}) response = self.connection.request(action='/domains/%s/records/%s' % (zone_id, record_id)).object record = self._to_record(data=response, zone=zone) return record def create_zone(self, domain, type='master', ttl=None, extra=None): extra = extra if extra else {} # Email address is required if 'email' not in extra: raise ValueError('"email" key must be present in extra dictionary') payload = {'name': domain, 'emailAddress': extra['email'], 'recordsList': {'records': []}} if ttl: payload['ttl'] = ttl if 'comment' in extra: payload['comment'] = extra['comment'] data = {'domains': [payload]} response = self.connection.async_request(action='/domains', method='POST', data=data) zone = self._to_zone(data=response.object['response']['domains'][0]) return zone def update_zone(self, zone, domain=None, type=None, ttl=None, extra=None): # Only ttl, comment and email address can be changed extra = extra if extra else {} if domain: raise LibcloudError('Domain cannot be changed', driver=self) data = {} if ttl: data['ttl'] = int(ttl) if 'email' in extra: data['emailAddress'] = extra['email'] if 'comment' in extra: data['comment'] = extra['comment'] type = type if type else zone.type ttl = ttl if ttl else zone.ttl self.connection.set_context({'resource': 'zone', 'id': zone.id}) self.connection.async_request(action='/domains/%s' % (zone.id), method='PUT', data=data) merged = merge_valid_keys(params=copy.deepcopy(zone.extra), valid_keys=VALID_ZONE_EXTRA_PARAMS, extra=extra) updated_zone = get_new_obj(obj=zone, klass=Zone, attributes={'type': type, 'ttl': ttl, 'extra': merged}) return updated_zone def create_record(self, name, zone, type, data, extra=None): # Name must be a FQDN - e.g. if domain is "foo.com" then a record # name is "bar.foo.com" extra = extra if extra else {} name = self._to_full_record_name(domain=zone.domain, name=name) data = {'name': name, 'type': self.RECORD_TYPE_MAP[type], 'data': data} if 'ttl' in extra: data['ttl'] = int(extra['ttl']) if 'priority' in extra: data['priority'] = int(extra['priority']) payload = {'records': [data]} self.connection.set_context({'resource': 'zone', 'id': zone.id}) response = self.connection.async_request(action='/domains/%s/records' % (zone.id), data=payload, method='POST').object record = self._to_record(data=response['response']['records'][0], zone=zone) return record def update_record(self, record, name=None, type=None, data=None, extra=None): # Only data, ttl, and comment attributes can be modified, but name # attribute must always be present. extra = extra if extra else {} name = self._to_full_record_name(domain=record.zone.domain, name=record.name) payload = {'name': name} if data: payload['data'] = data if 'ttl' in extra: payload['ttl'] = extra['ttl'] if 'comment' in extra: payload['comment'] = extra['comment'] type = type if type is not None else record.type data = data if data else record.data self.connection.set_context({'resource': 'record', 'id': record.id}) self.connection.async_request(action='/domains/%s/records/%s' % (record.zone.id, record.id), method='PUT', data=payload) merged = merge_valid_keys(params=copy.deepcopy(record.extra), valid_keys=VALID_RECORD_EXTRA_PARAMS, extra=extra) updated_record = get_new_obj(obj=record, klass=Record, attributes={'type': type, 'data': data, 'driver': self, 'extra': merged}) return updated_record def delete_zone(self, zone): self.connection.set_context({'resource': 'zone', 'id': zone.id}) self.connection.async_request(action='/domains/%s' % (zone.id), method='DELETE') return True def delete_record(self, record): self.connection.set_context({'resource': 'record', 'id': record.id}) self.connection.async_request(action='/domains/%s/records/%s' % (record.zone.id, record.id), method='DELETE') return True def ex_iterate_ptr_records(self, device): """ Return a generator to iterate over existing PTR Records. The ``device`` should be an instance of one of these: :class:`libcloud.compute.base.Node` :class:`libcloud.loadbalancer.base.LoadBalancer` And it needs to have the following ``extra`` fields set: service_name - the service catalog name for the device uri - the URI pointing to the GET endpoint for the device Those are automatically set for you if you got the device from the Rackspace driver for that service. For example: server = rs_compute.ex_get_node_details(id) ptr_iter = rs_dns.ex_list_ptr_records(server) loadbalancer = rs_lbs.get_balancer(id) ptr_iter = rs_dns.ex_list_ptr_records(loadbalancer) Note: the Rackspace DNS API docs indicate that the device 'href' is optional, but testing does not bear this out. It throws a 400 Bad Request error if you do not pass in the 'href' from the server or loadbalancer. So ``device`` is required. :param device: the device that owns the IP :rtype: ``generator`` of :class:`RackspacePTRRecord` """ _check_ptr_extra_fields(device) params = {'href': device.extra['uri']} service_name = device.extra['service_name'] # without a valid context, the 404 on empty list will blow up # in the error-handling code self.connection.set_context({'resource': 'ptr_records'}) try: response = self.connection.request( action='/rdns/%s' % (service_name), params=params).object records = response['records'] link = dict(rel=service_name, **params) for item in records: record = self._to_ptr_record(data=item, link=link) yield record except BaseHTTPError as exc: # 404 just means empty list if exc.code == 404: return raise def ex_get_ptr_record(self, service_name, record_id): """ Get a specific PTR record by id. :param service_name: the service catalog name of the linked device(s) i.e. cloudLoadBalancers or cloudServersOpenStack :param record_id: the id (i.e. PTR-12345) of the PTR record :rtype: instance of :class:`RackspacePTRRecord` """ self.connection.set_context({'resource': 'record', 'id': record_id}) response = self.connection.request( action='/rdns/%s/%s' % (service_name, record_id)).object item = next(iter(response['recordsList']['records'])) return self._to_ptr_record(data=item, link=response['link']) def ex_create_ptr_record(self, device, ip, domain, extra=None): """ Create a PTR record for a specific IP on a specific device. The ``device`` should be an instance of one of these: :class:`libcloud.compute.base.Node` :class:`libcloud.loadbalancer.base.LoadBalancer` And it needs to have the following ``extra`` fields set: service_name - the service catalog name for the device uri - the URI pointing to the GET endpoint for the device Those are automatically set for you if you got the device from the Rackspace driver for that service. For example: server = rs_compute.ex_get_node_details(id) rs_dns.create_ptr_record(server, ip, domain) loadbalancer = rs_lbs.get_balancer(id) rs_dns.create_ptr_record(loadbalancer, ip, domain) :param device: the device that owns the IP :param ip: the IP for which you want to set reverse DNS :param domain: the fqdn you want that IP to represent :param extra: a ``dict`` with optional extra values: ttl - the time-to-live of the PTR record :rtype: instance of :class:`RackspacePTRRecord` """ _check_ptr_extra_fields(device) if extra is None: extra = {} # the RDNS API reverse the name and data fields for PTRs # the record name *should* be the ip and the data the fqdn data = { "name": domain, "type": RecordType.PTR, "data": ip } if 'ttl' in extra: data['ttl'] = extra['ttl'] payload = { "recordsList": { "records": [data] }, "link": { "content": "", "href": device.extra['uri'], "rel": device.extra['service_name'], } } response = self.connection.async_request( action='/rdns', method='POST', data=payload).object item = next(iter(response['response']['records'])) return self._to_ptr_record(data=item, link=payload['link']) def ex_update_ptr_record(self, record, domain=None, extra=None): """ Update a PTR record for a specific IP on a specific device. If you need to change the domain or ttl, use this API to update the record by deleting the old one and creating a new one. :param record: the original :class:`RackspacePTRRecord` :param domain: the fqdn you want that IP to represent :param extra: a ``dict`` with optional extra values: ttl - the time-to-live of the PTR record :rtype: instance of :class:`RackspacePTRRecord` """ if domain is not None and domain == record.domain: domain = None if extra is not None: extra = dict(extra) for key in extra: if key in record.extra and record.extra[key] == extra[key]: del extra[key] if domain is None and not extra: # nothing to do, it already matches return record _check_ptr_extra_fields(record) ip = record.ip self.ex_delete_ptr_record(record) # records have the same metadata in 'extra' as the original device # so you can pass the original record object in instead return self.ex_create_ptr_record(record, ip, domain, extra=extra) def ex_delete_ptr_record(self, record): """ Delete an existing PTR Record :param record: the original :class:`RackspacePTRRecord` :rtype: ``bool`` """ _check_ptr_extra_fields(record) self.connection.set_context({'resource': 'record', 'id': record.id}) self.connection.async_request( action='/rdns/%s' % (record.extra['service_name']), method='DELETE', params={'href': record.extra['uri'], 'ip': record.ip}, ) return True def _to_zone(self, data): id = data['id'] domain = data['name'] type = 'master' ttl = data.get('ttl', 0) extra = {} if 'emailAddress' in data: extra['email'] = data['emailAddress'] if 'comment' in data: extra['comment'] = data['comment'] zone = Zone(id=str(id), domain=domain, type=type, ttl=int(ttl), driver=self, extra=extra) return zone def _to_record(self, data, zone): id = data['id'] fqdn = data['name'] name = self._to_partial_record_name(domain=zone.domain, name=fqdn) type = self._string_to_record_type(data['type']) record_data = data['data'] extra = {'fqdn': fqdn} for key in VALID_RECORD_EXTRA_PARAMS: if key in data: extra[key] = data[key] record = Record(id=str(id), name=name, type=type, data=record_data, zone=zone, driver=self, ttl=extra.get('ttl', None), extra=extra) return record def _to_ptr_record(self, data, link): id = data['id'] ip = data['data'] domain = data['name'] extra = {'uri': link['href'], 'service_name': link['rel']} for key in VALID_RECORD_EXTRA_PARAMS: if key in data: extra[key] = data[key] record = RackspacePTRRecord(id=str(id), ip=ip, domain=domain, driver=self, extra=extra) return record def _to_full_record_name(self, domain, name): """ Build a FQDN from a domain and record name. :param domain: Domain name. :type domain: ``str`` :param name: Record name. :type name: ``str`` """ if name: name = '%s.%s' % (name, domain) else: name = domain return name def _to_partial_record_name(self, domain, name): """ Remove domain portion from the record name. :param domain: Domain name. :type domain: ``str`` :param name: Full record name (fqdn). :type name: ``str`` """ if name == domain: # Map "root" record names to None to be consistent with other # drivers return None # Strip domain portion name = name.replace('.%s' % (domain), '') return name def _ex_connection_class_kwargs(self): kwargs = self.openstack_connection_kwargs() kwargs['region'] = self.region return kwargs def _rackspace_result_has_more(response, result_length, limit): # If rackspace returns less than the limit, then we've reached the end of # the result set. if result_length < limit: return False # Paginated results return links to the previous and next sets of data, but # 'next' only exists when there is more to get. for item in response.get('links', ()): if item['rel'] == 'next': return True return False def _check_ptr_extra_fields(device_or_record): if not (hasattr(device_or_record, 'extra') and isinstance(device_or_record.extra, dict) and device_or_record.extra.get('uri') is not None and device_or_record.extra.get('service_name') is not None): raise LibcloudError("Can't create PTR Record for %s because it " "doesn't have a 'uri' and 'service_name' in " "'extra'" % device_or_record)