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.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']


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 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 _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, 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