# # # from __future__ import absolute_import, division, print_function, \ unicode_literals from requests import HTTPError, Session, post from collections import defaultdict import logging import string import time from ..record import Record from .base import BaseProvider def add_trailing_dot(s): assert s assert s[-1] != '.' return s + '.' def remove_trailing_dot(s): assert s assert s[-1] == '.' return s[:-1] def escape_semicolon(s): assert s return string.replace(s, ';', '\;') def unescape_semicolon(s): assert s return string.replace(s, '\;', ';') class RackspaceProvider(BaseProvider): SUPPORTS_GEO = False SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NS', 'PTR', 'SPF', 'TXT')) TIMEOUT = 5 def __init__(self, id, username, api_key, ratelimit_delay=0.0, *args, **kwargs): ''' Rackspace API v1 Provider rackspace: class: octodns.provider.rackspace.RackspaceProvider # The the username to authenticate with (required) username: username # The api key that grants access for that user (required) api_key: api-key ''' self.log = logging.getLogger('RackspaceProvider[{}]'.format(id)) super(RackspaceProvider, self).__init__(id, *args, **kwargs) auth_token, dns_endpoint = self._get_auth_token(username, api_key) self.dns_endpoint = dns_endpoint self.ratelimit_delay = float(ratelimit_delay) sess = Session() sess.headers.update({'X-Auth-Token': auth_token}) self._sess = sess # Map record type, name, and data to an id when populating so that # we can find the id for update and delete operations. self._id_map = {} def _get_auth_token(self, username, api_key): ret = post('https://identity.api.rackspacecloud.com/v2.0/tokens', json={"auth": { "RAX-KSKEY:apiKeyCredentials": {"username": username, "apiKey": api_key}}}, ) cloud_dns_endpoint = \ [x for x in ret.json()['access']['serviceCatalog'] if x['name'] == 'cloudDNS'][0]['endpoints'][0]['publicURL'] return ret.json()['access']['token']['id'], cloud_dns_endpoint def _get_zone_id_for(self, zone): ret = self._request('GET', 'domains', pagination_key='domains') return [x for x in ret if x['name'] == zone.name[:-1]][0]['id'] def _request(self, method, path, data=None, pagination_key=None): self.log.debug('_request: method=%s, path=%s', method, path) url = '{}/{}'.format(self.dns_endpoint, path) if pagination_key: resp = self._paginated_request_for_url(method, url, data, pagination_key) else: resp = self._request_for_url(method, url, data) time.sleep(self.ratelimit_delay) return resp def _request_for_url(self, method, url, data): resp = self._sess.request(method, url, json=data, timeout=self.TIMEOUT) self.log.debug('_request: status=%d', resp.status_code) resp.raise_for_status() return resp def _paginated_request_for_url(self, method, url, data, pagination_key): acc = [] resp = self._sess.request(method, url, json=data, timeout=self.TIMEOUT) self.log.debug('_request: status=%d', resp.status_code) resp.raise_for_status() acc.extend(resp.json()[pagination_key]) next_page = [x for x in resp.json().get('links', []) if x['rel'] == 'next'] if next_page: url = next_page[0]['href'] acc.extend(self._paginated_request_for_url(method, url, data, pagination_key)) return acc else: return acc def _post(self, path, data=None): return self._request('POST', path, data=data) def _put(self, path, data=None): return self._request('PUT', path, data=data) def _delete(self, path, data=None): return self._request('DELETE', path, data=data) @staticmethod def _as_unicode(s, codec): if not isinstance(s, unicode): return unicode(s, codec) return s @classmethod def _key_for_record(cls, rs_record): return cls._as_unicode(rs_record['type'], 'ascii'), \ cls._as_unicode(rs_record['name'], 'utf-8'), \ cls._as_unicode(rs_record['data'], 'utf-8') def _data_for_multiple(self, rrset): return { 'type': rrset[0]['type'], 'values': [r['data'] for r in rrset], 'ttl': rrset[0]['ttl'] } _data_for_A = _data_for_multiple _data_for_AAAA = _data_for_multiple def _data_for_NS(self, rrset): return { 'type': rrset[0]['type'], 'values': [add_trailing_dot(r['data']) for r in rrset], 'ttl': rrset[0]['ttl'] } def _data_for_single(self, record): return { 'type': record[0]['type'], 'value': add_trailing_dot(record[0]['data']), 'ttl': record[0]['ttl'] } _data_for_ALIAS = _data_for_single _data_for_CNAME = _data_for_single _data_for_PTR = _data_for_single def _data_for_textual(self, rrset): return { 'type': rrset[0]['type'], 'values': [escape_semicolon(r['data']) for r in rrset], 'ttl': rrset[0]['ttl'] } _data_for_SPF = _data_for_textual _data_for_TXT = _data_for_textual def _data_for_MX(self, rrset): values = [] for record in rrset: values.append({ 'priority': record['priority'], 'value': add_trailing_dot(record['data']), }) return { 'type': rrset[0]['type'], 'values': values, 'ttl': rrset[0]['ttl'] } def populate(self, zone, target=False, lenient=False): self.log.debug('populate: name=%s', zone.name) resp_data = None try: domain_id = self._get_zone_id_for(zone) resp_data = self._request('GET', 'domains/{}/records'.format(domain_id), pagination_key='records') self.log.debug('populate: loaded') except HTTPError as e: if e.response.status_code == 401: # Nicer error message for auth problems raise Exception('Rackspace request unauthorized') elif e.response.status_code == 404: # Zone not found leaves the zone empty instead of failing. return raise before = len(zone.records) if resp_data: records = self._group_records(resp_data) for record_type, records_of_type in records.items(): for raw_record_name, record_set in records_of_type.items(): data_for = getattr(self, '_data_for_{}'.format(record_type)) record_name = zone.hostname_from_fqdn(raw_record_name) record = Record.new(zone, record_name, data_for(record_set), source=self) zone.add_record(record) self.log.info('populate: found %s records', len(zone.records) - before) def _group_records(self, all_records): records = defaultdict(lambda: defaultdict(list)) for record in all_records: self._id_map[self._key_for_record(record)] = record['id'] records[record['type']][record['name']].append(record) return records @staticmethod def _record_for_single(record, value): return { 'name': remove_trailing_dot(record.fqdn), 'type': record._type, 'data': value, 'ttl': max(record.ttl, 300), } _record_for_A = _record_for_single _record_for_AAAA = _record_for_single @staticmethod def _record_for_named(record, value): return { 'name': remove_trailing_dot(record.fqdn), 'type': record._type, 'data': remove_trailing_dot(value), 'ttl': max(record.ttl, 300), } _record_for_NS = _record_for_named _record_for_ALIAS = _record_for_named _record_for_CNAME = _record_for_named _record_for_PTR = _record_for_named @staticmethod def _record_for_textual(record, value): return { 'name': remove_trailing_dot(record.fqdn), 'type': record._type, 'data': unescape_semicolon(value), 'ttl': max(record.ttl, 300), } _record_for_SPF = _record_for_textual _record_for_TXT = _record_for_textual @staticmethod def _record_for_MX(record, value): return { 'name': remove_trailing_dot(record.fqdn), 'type': record._type, 'data': remove_trailing_dot(value.exchange), 'ttl': max(record.ttl, 300), 'priority': value.preference } def _get_values(self, record): try: return record.values except AttributeError: return [record.value] def _mod_Create(self, change): return self._create_given_change_values(change, self._get_values(change.new)) def _create_given_change_values(self, change, values): transformer = getattr(self, "_record_for_{}".format(change.new._type)) return [transformer(change.new, v) for v in values] def _mod_Update(self, change): existing_values = self._get_values(change.existing) new_values = self._get_values(change.new) # A reduction in number of values in an update record needs # to get upgraded into a Delete change for the removed values. deleted_values = set(existing_values) - set(new_values) delete_out = self._delete_given_change_values(change, deleted_values) # An increase in number of values in an update record needs # to get upgraded into a Create change for the added values. create_values = set(new_values) - set(existing_values) create_out = self._create_given_change_values(change, create_values) update_out = [] update_values = set(new_values).intersection(set(existing_values)) for value in update_values: transformer = getattr(self, "_record_for_{}".format(change.new._type)) prior_rs_record = transformer(change.existing, value) prior_key = self._key_for_record(prior_rs_record) next_rs_record = transformer(change.new, value) next_key = self._key_for_record(next_rs_record) next_rs_record["id"] = self._id_map[prior_key] del next_rs_record["type"] update_out.append(next_rs_record) self._id_map[next_key] = self._id_map[prior_key] del self._id_map[prior_key] return create_out, update_out, delete_out def _mod_Delete(self, change): return self._delete_given_change_values(change, self._get_values( change.existing)) def _delete_given_change_values(self, change, values): transformer = getattr(self, "_record_for_{}".format( change.existing._type)) out = [] for value in values: rs_record = transformer(change.existing, value) key = self._key_for_record(rs_record) out.append('id=' + self._id_map[key]) del self._id_map[key] return out def _apply(self, plan): desired = plan.desired changes = plan.changes self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, len(changes)) # Creates, updates, and deletes are processed by different endpoints # and are broken out by record-set entries; pre-process everything # into these buckets in order to minimize the number of API calls. domain_id = self._get_zone_id_for(desired) creates = [] updates = [] deletes = [] for change in changes: if change.__class__.__name__ == 'Create': creates += self._mod_Create(change) elif change.__class__.__name__ == 'Update': add_creates, add_updates, add_deletes = self._mod_Update( change) creates += add_creates updates += add_updates deletes += add_deletes else: assert change.__class__.__name__ == 'Delete' deletes += self._mod_Delete(change) if deletes: params = "&".join(sorted(deletes)) self._delete('domains/{}/records?{}'.format(domain_id, params)) if updates: data = {"records": sorted(updates, key=lambda v: v['name'])} self._put('domains/{}/records'.format(domain_id), data=data) if creates: data = {"records": sorted(creates, key=lambda v: v['type'] + v['name'] + v.get('data', ''))} self._post('domains/{}/records'.format(domain_id), data=data)