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.
from libcloud.common.openstack import OpenStackDriverMixin

__all__ = [
    'RackspaceUSDNSDriver',
    'RackspaceUKDNSDriver'
]

from libcloud.utils.py3 import httplib
import copy

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

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


class RackspaceDNSResponse(OpenStack_1_1_Response):
    """
    Rackspace DNS Response class.
    """

    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))


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)

    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

    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'

    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):
        if region not in ['us', 'uk']:
            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',
    }

    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


[docs]class RackspaceUSDNSDriver(RackspaceDNSDriver): name = 'Rackspace DNS (US)' type = Provider.RACKSPACE_US def __init__(self, *args, **kwargs): kwargs['region'] = 'us' super(RackspaceUSDNSDriver, self).__init__(*args, **kwargs)
[docs]class RackspaceUKDNSDriver(RackspaceDNSDriver): name = 'Rackspace DNS (UK)' type = Provider.RACKSPACE_UK def __init__(self, *args, **kwargs): kwargs['region'] = 'uk' super(RackspaceUKDNSDriver, self).__init__(*args, **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)