| # 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'] |
| |
| |
| 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): |
| 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) |