#!/usr/bin/env python # -*- coding: utf-8 -*- # Copyright 2012 Rackspace # All Rights Reserved. # # Licensed 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 functools import wraps import json import re import time import six import pyrax from pyrax.client import BaseClient from pyrax.cloudloadbalancers import CloudLoadBalancer import pyrax.exceptions as exc from pyrax.manager import BaseManager from pyrax.resource import BaseResource import pyrax.utils as utils # How long (in seconds) to wait for a response from async operations DEFAULT_TIMEOUT = 5 # How long (in seconds) to wait in between checks for async completion DEFAULT_DELAY = 0.5 # How many times to retry a GET before raising an error DEFAULT_RETRY = 3 def assure_domain(fnc): @wraps(fnc) def _wrapped(self, domain, *args, **kwargs): if not isinstance(domain, CloudDNSDomain): # Must be the ID or name. Try ID first: try: domain = self._manager.get(domain) except exc.NotFound: domain = self._manager.find(name=domain) return fnc(self, domain, *args, **kwargs) return _wrapped class CloudDNSRecord(BaseResource): """ This class represents a domain record. """ GET_DETAILS = False # Initialize the supported attributes. type = None name = None data = None priority = None ttl = None comment = None def update(self, data=None, priority=None, ttl=None, comment=None): """ Modifies this record. """ return self.manager.update_record(self.domain_id, self, data=data, priority=priority, ttl=ttl, comment=comment) def get(self): """ Gets the full information for an existing record for this domain. """ return self.manager.get_record(self.domain_id, self) def delete(self): """ Deletes an existing record for this domain. """ return self.manager.delete_record(self.domain_id, self) class CloudDNSDomain(BaseResource): """ This class represents a DNS domain. """ def delete(self, delete_subdomains=False): """ Deletes this domain and all of its resource records. If this domain has subdomains, each subdomain will now become a root domain. If you wish to also delete any subdomains, pass True to 'delete_subdomains'. """ self.manager.delete(self, delete_subdomains=delete_subdomains) def changes_since(self, date_or_datetime): """ Gets the changes for this domain since the specified date/datetime. The date can be one of: - a Python datetime object - a Python date object - a string in the format 'YYYY-MM-YY HH:MM:SS' - a string in the format 'YYYY-MM-YY' It returns a list of dicts, whose keys depend on the specific change that was made. A simple example of such a change dict: {u'accountId': 000000, u'action': u'update', u'changeDetails': [{u'field': u'serial_number', u'newValue': u'1354038941', u'originalValue': u'1354038940'}, {u'field': u'updated_at', u'newValue': u'Tue Nov 27 17:55:41 UTC 2012', u'originalValue': u'Tue Nov 27 17:55:40 UTC 2012'}], u'domain': u'example.com', u'targetId': 00000000, u'targetType': u'Domain'} """ return self.manager.changes_since(self, date_or_datetime) def export(self): """ Provides the BIND (Berkeley Internet Name Domain) 9 formatted contents of the requested domain. This call is for a single domain only, and as such, does not provide subdomain information. Sample export: {u'accountId': 000000, u'contentType': u'BIND_9', u'contents': u'example.com.\t3600\tIN\tSOA\tns.rackspace.com. ' 'foo@example.com. 1354202974 21600 3600 1814400 500' 'example.com.\t3600\tIN\tNS\tdns1.stabletransit.com.' 'example.com.\t3600\tIN\tNS\tdns2.stabletransit.com.', u'id': 1111111} """ return self.manager.export_domain(self) def update(self, emailAddress=None, ttl=None, comment=None): """ Provides a way to modify the following attributes of a domain entry: - email address - ttl setting - comment """ return self.manager.update_domain(self, emailAddress=emailAddress, ttl=ttl, comment=comment) def list_subdomains(self, limit=None, offset=None): """ Returns a list of all subdomains for this domain. """ return self.manager.list_subdomains(self, limit=limit, offset=offset) def list_records(self, limit=None, offset=None): """ Returns a list of all records configured for this domain. """ return self.manager.list_records(self, limit=limit, offset=offset) def search_records(self, record_type, name=None, data=None): """ Returns a list of all records configured for this domain that match the supplied search criteria. """ return self.manager.search_records(self, record_type=record_type, name=name, data=data) def find_record(self, record_type, name=None, data=None): """ Returns a single record for this domain that matches the supplied search criteria. If no record matches, a DomainRecordNotFound exception will be raised. If more than one matches, a DomainRecordNotUnique exception will be raised. """ matches = self.manager.search_records(self, record_type=record_type, name=name, data=data) if not matches: raise exc.DomainRecordNotFound elif len(matches) > 1: raise exc.DomainRecordNotUnique return matches[0] def add_records(self, records): """ Adds the records to this domain. Each record should be a dict with the following keys: - type (required) - name (required) - data (required) - ttl (optional) - comment (optional) - priority (required for MX and SRV records; forbidden otherwise) """ return self.manager.add_records(self, records) # Create an alias, so that adding a single record is more intuitive add_record = add_records def get_record(self, record): """ Gets the full information for an existing record for this domain. """ return self.manager.get_record(self, record) def update_record(self, record, data=None, priority=None, ttl=None, comment=None): """ Modifies an existing record for this domain. """ return self.manager.update_record(self, record, data=data, priority=priority, ttl=ttl, comment=comment) def delete_record(self, record): """ Deletes an existing record for this domain. """ return self.manager.delete_record(self, record) class CloudDNSPTRRecord(object): """ This represents a Cloud DNS PTR record (reverse DNS). """ def __init__(self, data=None, device=None): self.type = self.id = self.data = self.name = None self.ttl = self.comment = None if data: for key, val in data.items(): setattr(self, key, val) self.device = device def delete(self): """ Deletes this PTR record from its device. """ return pyrax.cloud_dns.delete_ptr_records(self.device, self.data) def __repr__(self): reprkeys = ("id", "data", "name", "ttl") info = ", ".join("%s=%s" % (key, getattr(self, key)) for key in reprkeys) return "<%s %s>" % (self.__class__.__name__, info) class CloudDNSManager(BaseManager): def __init__(self, api, resource_class=None, response_key=None, plural_response_key=None, uri_base=None): super(CloudDNSManager, self).__init__(api, resource_class=resource_class, response_key=response_key, plural_response_key=plural_response_key, uri_base=uri_base) self._paging = {"domain": {}, "subdomain": {}, "record": {}} self._reset_paging(service="all") self._timeout = DEFAULT_TIMEOUT self._delay = DEFAULT_DELAY def _create_body(self, name, emailAddress, ttl=3600, comment=None, subdomains=None, records=None): """ Creates the appropriate dict for creating a new domain. """ if subdomains is None: subdomains = [] if records is None: records = [] body = {"domains": [{ "name": name, "emailAddress": emailAddress, "ttl": ttl, "comment": comment, "subdomains": { "domains": subdomains }, "recordsList": { "records": records }, }]} return body def _set_timeout(self, timeout): """ Changes the duration for which the program will wait for a response from the DNS system. Setting the timeout to zero will make that program wait an indefinite amount of time. """ self._timeout = timeout def _set_delay(self, delay): """ Changes the interval that the program will pause in between attempts to see if a request has completed. """ self._delay = delay def _reset_paging(self, service, body=None): """ Resets the internal attributes when there is no current paging request. """ if service == "all": for svc in self._paging.keys(): svc_dct = self._paging[svc] svc_dct["next_uri"] = svc_dct["prev_uri"] = None svc_dct["total_entries"] = None return svc_dct = self._paging[service] svc_dct["next_uri"] = svc_dct["prev_uri"] = None svc_dct["total_entries"] = None if not body: return svc_dct["total_entries"] = body.get("totalEntries") links = body.get("links") uri_base = self.uri_base if links: for link in links: href = link["href"] pos = href.index(uri_base) page_uri = href[pos - 1:] if link["rel"] == "next": svc_dct["next_uri"] = page_uri elif link["rel"] == "previous": svc_dct["prev_uri"] = page_uri def _get_pagination_qs(self, limit, offset): pagination_items = [] if limit is not None: pagination_items.append("limit=%s" % limit) if offset is not None: pagination_items.append("offset=%s" % offset) qs = "&".join(pagination_items) qs = "?%s" % qs if qs else "" return qs def list(self, limit=None, offset=None): """Gets a list of all domains, or optionally a page of domains.""" uri = "/%s%s" % (self.uri_base, self._get_pagination_qs(limit, offset)) return self._list(uri) def _list(self, uri, obj_class=None, list_all=False): """ Handles the communication with the API when getting a full listing of the resources managed by this class. """ resp, resp_body = self._retry_get(uri) if obj_class is None: obj_class = self.resource_class data = resp_body[self.plural_response_key] ret = [obj_class(self, res, loaded=False) for res in data if res] self._reset_paging("domain", resp_body) if list_all: dom_paging = self._paging.get("domain", {}) while dom_paging.get("next_uri"): next_uri = dom_paging.get("next_uri") ret.extend(self._list(uri=next_uri, obj_class=obj_class, list_all=False)) return ret def list_previous_page(self): """ When paging through results, this will return the previous page, using the same limit. If there are no more results, a NoMoreResults exception will be raised. """ uri = self._paging.get("domain", {}).get("prev_uri") if uri is None: raise exc.NoMoreResults("There are no previous pages of domains " "to list.") return self._list(uri) def list_next_page(self): """ When paging through results, this will return the next page, using the same limit. If there are no more results, a NoMoreResults exception will be raised. """ uri = self._paging.get("domain", {}).get("next_uri") if uri is None: raise exc.NoMoreResults("There are no more pages of domains to " "list.") return self._list(uri) def _get(self, uri): """ Handles the communication with the API when getting a specific resource managed by this class. Because DNS returns a different format for the body, the BaseManager method must be overridden here. """ uri = "%s?showRecords=false&showSubdomains=false" % uri resp, body = self._retry_get(uri) body["records"] = [] return self.resource_class(self, body, loaded=True) def _retry_get(self, uri): """ Handles GET calls to the Cloud DNS API in order to retry on empty body responses. """ for i in six.moves.range(DEFAULT_RETRY): resp, body = self.api.method_get(uri) if body: return resp, body # Tried too many times raise exc.ServiceResponseFailure("The Cloud DNS service failed to " "respond to the request.") def _async_call(self, uri, body=None, method="GET", error_class=None, has_response=True, *args, **kwargs): """ Handles asynchronous call/responses for the DNS API. Returns the response headers and body if the call was successful. If an error status is returned, and the 'error_class' parameter is specified, that class of error will be raised with the details from the response. If no error class is specified, the response headers and body will be returned to the calling method, which will have to handle the result. """ api_methods = { "GET": self._retry_get, "POST": self.api.method_post, "PUT": self.api.method_put, "DELETE": self.api.method_delete, } api_method = api_methods[method] if body is None: resp, resp_body = api_method(uri, *args, **kwargs) else: resp, resp_body = api_method(uri, body=body, *args, **kwargs) callbackURL = resp_body["callbackUrl"].split("/status/")[-1] massagedURL = "/status/%s?showDetails=true" % callbackURL start = time.time() timed_out = False while (resp_body["status"] == "RUNNING") and not timed_out: resp_body = None while resp_body is None and not timed_out: resp, resp_body = self._retry_get(massagedURL) if self._timeout: timed_out = ((time.time() - start) > self._timeout) time.sleep(self._delay) if timed_out: raise exc.DNSCallTimedOut("The API call to '%s' did not complete " "after %s seconds." % (uri, self._timeout)) if error_class and (resp_body["status"] == "ERROR"): # This call will handle raising the error. self._process_async_error(resp_body, error_class) if has_response: ret = resp, resp_body["response"] else: ret = resp, resp_body try: resp_body = json.loads(resp_body) except Exception: pass return ret def _process_async_error(self, resp_body, error_class): """ The DNS API does not return a consistent format for their error messages. This abstracts out the differences in order to present a single unified message in the exception to be raised. """ def _fmt_error(err): # Remove the cumbersome Java-esque message details = err.get("details", "").replace("\n", " ") if not details: details = err.get("message", "") return "%s (%s)" % (details, err.get("code", "")) error = resp_body.get("error", "") if "failedItems" in error: # Multi-error response faults = error.get("failedItems", {}).get("faults", []) msgs = [_fmt_error(fault) for fault in faults] msg = "\n".join(msgs) else: msg = _fmt_error(error) raise error_class(msg) def _create(self, uri, body, records=None, subdomains=None, return_none=False, return_raw=False, **kwargs): """ Handles the communication with the API when creating a new resource managed by this class. Since DNS works completely differently for create() than the other APIs, this method overrides the default BaseManager behavior. If 'records' are supplied, they should be a list of dicts. Each record dict should have the following format: {"name": "example.com", "type": "A", "data": "192.0.2.17", "ttl": 86400} If 'subdomains' are supplied, they should be a list of dicts. Each subdomain dict should have the following format: {"name": "sub1.example.com", "comment": "1st sample subdomain", "emailAddress": "sample@rackspace.com"} """ self.run_hooks("modify_body_for_create", body, **kwargs) resp, resp_body = self._async_call(uri, body=body, method="POST", error_class=exc.DomainCreationFailed) response_body = resp_body[self.response_key][0] return self.resource_class(self, response_body) def delete(self, domain, delete_subdomains=False): """ Deletes the specified domain and all of its resource records. If the domain has subdomains, each subdomain will now become a root domain. If you wish to also delete any subdomains, pass True to 'delete_subdomains'. """ uri = "/%s/%s" % (self.uri_base, utils.get_id(domain)) if delete_subdomains: uri = "%s?deleteSubdomains=true" % uri resp, resp_body = self._async_call(uri, method="DELETE", error_class=exc.DomainDeletionFailed, has_response=False) def findall(self, **kwargs): """ Finds all items with attributes matching ``**kwargs``. Normally this isn't very efficient, since the default action is to load the entire list and then filter on the Python side, but the DNS API provides a more efficient search option when filtering on name. So if the filter is on name, use that; otherwise, use the default. """ if (len(kwargs) == 1) and ("name" in kwargs): # Filtering on name; use the more efficient method. nm = kwargs["name"].lower() uri = "/%s?name=%s" % (self.uri_base, nm) matches = self._list(uri, list_all=True) return [match for match in matches if match.name.lower() == nm] else: return super(CloudDNSManager, self).findall(**kwargs) def changes_since(self, domain, date_or_datetime): """ Gets the changes for a domain since the specified date/datetime. The date can be one of: - a Python datetime object - a Python date object - a string in the format 'YYYY-MM-YY HH:MM:SS' - a string in the format 'YYYY-MM-YY' It returns a list of dicts, whose keys depend on the specific change that was made. A simple example of such a change dict: {u'accountId': 000000, u'action': u'update', u'changeDetails': [{u'field': u'serial_number', u'newValue': u'1354038941', u'originalValue': u'1354038940'}, {u'field': u'updated_at', u'newValue': u'Tue Nov 27 17:55:41 UTC 2012', u'originalValue': u'Tue Nov 27 17:55:40 UTC 2012'}], u'domain': u'example.com', u'targetId': 00000000, u'targetType': u'Domain'} """ domain_id = utils.get_id(domain) dt = utils.iso_time_string(date_or_datetime, show_tzinfo=True) uri = "/domains/%s/changes?since=%s" % (domain_id, dt) resp, body = self._retry_get(uri) return body.get("changes", []) def export_domain(self, domain): """ Provides the BIND (Berkeley Internet Name Domain) 9 formatted contents of the requested domain. This call is for a single domain only, and as such, does not provide subdomain information. Sample export: {u'accountId': 000000, u'contentType': u'BIND_9', u'contents': u'example.com.\t3600\tIN\tSOA\tns.rackspace.com. ' 'foo@example.com. 1354202974 21600 3600 1814400 500' 'example.com.\t3600\tIN\tNS\tdns1.stabletransit.com.' 'example.com.\t3600\tIN\tNS\tdns2.stabletransit.com.', u'id': 1111111} """ uri = "/domains/%s/export" % utils.get_id(domain) resp, resp_body = self._async_call(uri, method="GET", error_class=exc.NotFound) return resp_body.get("contents", "") def import_domain(self, domain_data): """ Takes a string in the BIND 9 format and creates a new domain. See the 'export_domain()' method for a description of the format. """ uri = "/domains/import" body = {"domains": [{ "contentType": "BIND_9", "contents": domain_data, }]} resp, resp_body = self._async_call(uri, method="POST", body=body, error_class=exc.DomainCreationFailed) return resp_body def update_domain(self, domain, emailAddress=None, ttl=None, comment=None): """ Provides a way to modify the following attributes of a domain record: - email address - ttl setting - comment """ if not any((emailAddress, ttl, comment)): raise exc.MissingDNSSettings( "No settings provided to update_domain().") uri = "/domains/%s" % utils.get_id(domain) body = {"comment": comment, "ttl": ttl, "emailAddress": emailAddress, } none_keys = [key for key, val in body.items() if val is None] for none_key in none_keys: body.pop(none_key) resp, resp_body = self._async_call(uri, method="PUT", body=body, error_class=exc.DomainUpdateFailed, has_response=False) return resp_body def list_subdomains(self, domain, limit=None, offset=None): """ Returns a list of all subdomains of the specified domain. """ # The commented-out uri is the official API, but it is # horribly slow. # uri = "/domains/%s/subdomains" % utils.get_id(domain) uri = "/domains?name=%s" % domain.name page_qs = self._get_pagination_qs(limit, offset) if page_qs: uri = "%s&%s" % (uri, page_qs[1:]) return self._list_subdomains(uri, domain.id) def _list_subdomains(self, uri, domain_id): resp, body = self._retry_get(uri) self._reset_paging("subdomain", body) subdomains = body.get("domains", []) return [CloudDNSDomain(self, subdomain, loaded=False) for subdomain in subdomains if subdomain["id"] != domain_id] def list_subdomains_previous_page(self): """ When paging through subdomain results, this will return the previous page, using the same limit. If there are no more results, a NoMoreResults exception will be raised. """ uri = self._paging.get("subdomain", {}).get("prev_uri") if uri is None: raise exc.NoMoreResults("There are no previous pages of subdomains " "to list.") return self._list_subdomains(uri) def list_subdomains_next_page(self): """ When paging through subdomain results, this will return the next page, using the same limit. If there are no more results, a NoMoreResults exception will be raised. """ uri = self._paging.get("subdomain", {}).get("next_uri") if uri is None: raise exc.NoMoreResults("There are no more pages of subdomains " "to list.") return self._list_subdomains(uri) def list_records(self, domain, limit=None, offset=None): """ Returns a list of all records configured for the specified domain. """ uri = "/domains/%s/records%s" % (utils.get_id(domain), self._get_pagination_qs(limit, offset)) return self._list_records(uri) def _list_records(self, uri): resp, body = self._retry_get(uri) self._reset_paging("record", body) # The domain ID will be in the URL pat = "domains/([^/]+)/records" mtch = re.search(pat, uri) dom_id = mtch.groups()[0] records = body.get("records", []) for record in records: record["domain_id"] = dom_id return [CloudDNSRecord(self, record, loaded=False) for record in records if record] def list_records_previous_page(self): """ When paging through record results, this will return the previous page, using the same limit. If there are no more results, a NoMoreResults exception will be raised. """ uri = self._paging.get("record", {}).get("prev_uri") if uri is None: raise exc.NoMoreResults("There are no previous pages of records " "to list.") return self._list_records(uri) def list_records_next_page(self): """ When paging through record results, this will return the next page, using the same limit. If there are no more results, a NoMoreResults exception will be raised. """ uri = self._paging.get("record", {}).get("next_uri") if uri is None: raise exc.NoMoreResults("There are no more pages of records to list.") return self._list_records(uri) def search_records(self, domain, record_type, name=None, data=None): """ Returns a list of all records configured for the specified domain that match the supplied search criteria. """ search_params = [] if name: search_params.append("name=%s" % name) if data: search_params.append("data=%s" % data) query_string = "&".join(search_params) dom_id = utils.get_id(domain) uri = "/domains/%s/records?type=%s" % (dom_id, record_type) if query_string: uri = "%s&%s" % (uri, query_string) resp, body = self._retry_get(uri) records = body.get("records", []) self._reset_paging("record", body) rec_paging = self._paging.get("record", {}) while rec_paging.get("next_uri"): resp, body = self._retry_get(rec_paging.get("next_uri")) self._reset_paging("record", body) records.extend(body.get("records", [])) for record in records: record["domain_id"] = dom_id return [CloudDNSRecord(self, record, loaded=False) for record in records if record] def add_records(self, domain, records): """ Adds the records to this domain. Each record should be a dict with the following keys: - type (required) - name (required) - data (required) - ttl (optional) - comment (optional) - priority (required for MX and SRV records; forbidden otherwise) """ if isinstance(records, dict): # Single record passed records = [records] dom_id = utils.get_id(domain) uri = "/domains/%s/records" % dom_id body = {"records": records} resp, resp_body = self._async_call(uri, method="POST", body=body, error_class=exc.DomainRecordAdditionFailed, has_response=False) records = resp_body.get("response", {}).get("records", []) for record in records: record["domain_id"] = dom_id return [CloudDNSRecord(self, record, loaded=False) for record in records if record] def get_record(self, domain, record): """ Gets the full information for an existing record for this domain. """ rec_id = utils.get_id(record) domain_id = utils.get_id(domain) uri = "/domains/%s/records/%s" % (domain_id, rec_id) resp, resp_body = self._retry_get(uri) resp_body['domain_id'] = domain_id return CloudDNSRecord(self, resp_body, loaded=False) def update_record(self, domain, record, data=None, priority=None, ttl=None, comment=None): """ Modifies an existing record for a domain. """ rec_id = utils.get_id(record) uri = "/domains/%s/records/%s" % (utils.get_id(domain), rec_id) body = {"name": record.name} all_opts = (("data", data), ("priority", priority), ("ttl", ttl), ("comment", comment)) opts = [(k, v) for k, v in all_opts if v is not None] body.update(dict(opts)) resp, resp_body = self._async_call(uri, method="PUT", body=body, error_class=exc.DomainRecordUpdateFailed, has_response=False) return resp_body def delete_record(self, domain, record): """ Deletes an existing record for a domain. """ uri = "/domains/%s/records/%s" % (utils.get_id(domain), utils.get_id(record)) resp, resp_body = self._async_call(uri, method="DELETE", error_class=exc.DomainRecordDeletionFailed, has_response=False) return resp_body def _get_ptr_details(self, device, device_type): """ Takes a device and device type and returns the corresponding HREF link and service name for use with PTR record management. """ if device_type.lower().startswith("load"): ep = pyrax._get_service_endpoint("load_balancer") svc = "loadbalancers" svc_name = "cloudLoadBalancers" else: ep = pyrax._get_service_endpoint("compute") svc = "servers" svc_name = "cloudServersOpenStack" href = "%s/%s/%s" % (ep, svc, utils.get_id(device)) return (href, svc_name) def _resolve_device_type(self, device): """ Given a device, determines if it is a CloudServer, a CloudLoadBalancer, or an invalid device. """ try: from tests.unit import fakes server_types = (pyrax.CloudServer, fakes.FakeServer) lb_types = (CloudLoadBalancer, fakes.FakeLoadBalancer, fakes.FakeDNSDevice) except ImportError: # Not running with tests server_types = (pyrax.CloudServer, ) lb_types = (CloudLoadBalancer, ) if isinstance(device, server_types): device_type = "server" elif isinstance(device, lb_types): device_type = "loadbalancer" else: raise exc.InvalidDeviceType("The device '%s' must be a CloudServer " "or a CloudLoadBalancer." % device) return device_type def list_ptr_records(self, device): """ Returns a list of all PTR records configured for this device. """ device_type = self._resolve_device_type(device) href, svc_name = self._get_ptr_details(device, device_type) uri = "/rdns/%s?href=%s" % (svc_name, href) try: resp, resp_body = self._retry_get(uri) except exc.NotFound: return [] records = [CloudDNSPTRRecord(rec, device) for rec in resp_body.get("records", [])] return records def add_ptr_records(self, device, records): """ Adds one or more PTR records to the specified device. """ device_type = self._resolve_device_type(device) href, svc_name = self._get_ptr_details(device, device_type) if not isinstance(records, (list, tuple)): records = [records] body = {"recordsList": { "records": records}, "link": { "content": "", "href": href, "rel": svc_name, }} uri = "/rdns" # This is a necessary hack, so here's why: if you attempt to add # PTR records to device, and you don't have rights to either the device # or the IP address, the DNS API will return a 401 - Unauthorized. # Unfortunately, the pyrax client interprets this as a bad auth token, # and there is no way to distinguish this from an actual authentication # failure. The client will attempt to re-authenticate as a result, and # will fail, due to the DNS API not having regional endpoints. The net # result is that an EndpointNotFound exception will be raised, which # we catch here and then raise a more meaningful exception. # The Rackspace DNS team is working on changing this to return a 403 # instead; when that happens this kludge can go away. try: resp, resp_body = self._async_call(uri, body=body, method="POST", error_class=exc.PTRRecordCreationFailed) except exc.EndpointNotFound: raise exc.InvalidPTRRecord("The domain/IP address information is not " "valid for this device.") return resp_body.get("records") records = [CloudDNSPTRRecord(rec, device) for rec in resp_body.get("records", [])] return records def update_ptr_record(self, device, record, domain_name, data=None, ttl=None, comment=None): """ Updates a PTR record with the supplied values. """ device_type = self._resolve_device_type(device) href, svc_name = self._get_ptr_details(device, device_type) try: rec_id = record.id except AttributeError: rec_id = record rec = {"name": domain_name, "id": rec_id, "type": "PTR", "data": data, } if ttl is not None: # Minimum TTL is 300 seconds rec["ttl"] = max(300, ttl) if comment is not None: # Maximum comment length is 160 chars rec["comment"] = comment[:160] body = {"recordsList": { "records": [rec]}, "link": { "content": "", "href": href, "rel": svc_name, }} uri = "/rdns" try: resp, resp_body = self._async_call(uri, body=body, method="PUT", has_response=False, error_class=exc.PTRRecordUpdateFailed) except exc.EndpointNotFound as e: raise exc.InvalidPTRRecord("The record domain/IP address " "information is not valid for this device.") return resp_body.get("status") == "COMPLETED" def delete_ptr_records(self, device, ip_address=None): """ Deletes the PTR records for the specified device. If 'ip_address' is supplied, only the PTR records with that IP address will be deleted. """ device_type = self._resolve_device_type(device) href, svc_name = self._get_ptr_details(device, device_type) uri = "/rdns/%s?href=%s" % (svc_name, href) if ip_address: uri = "%s&ip=%s" % (uri, ip_address) resp, resp_body = self._async_call(uri, method="DELETE", has_response=False, error_class=exc.PTRRecordDeletionFailed) return resp_body.get("status") == "COMPLETED" class CloudDNSClient(BaseClient): """ This is the primary class for interacting with Cloud DNS. """ name = "Cloud DNS" def _configure_manager(self): """ Creates a manager to handle the instances, and another to handle flavors. """ self._manager = CloudDNSManager(self, resource_class=CloudDNSDomain, response_key="domains", plural_response_key="domains", uri_base="domains") def method_get(self, uri, **kwargs): """ Overload the method_get function in order to retry on empty body responses from the Cloud DNS API """ for i in six.moves.range(3): resp, body = super(CloudDNSClient, self).method_get(uri, **kwargs) if body: return resp, body raise exc.ServiceResponseFailure("The Cloud DNS service failed to " "respond to the request.") def set_timeout(self, timeout): """ Sets the amount of time that calls will wait for a response from the DNS system before timing out. Setting the timeout to zero will cause execution to wait indefinitely until the call completes. """ self._manager._set_timeout(timeout) def set_delay(self, delay): """ Changes the interval that the program will pause in between attempts to see if a request has completed. """ self._manager._set_delay(delay) def list(self, limit=None, offset=None): """Returns a list of all resources.""" return self._manager.list(limit=limit, offset=offset) def list_previous_page(self): """Returns the previous page of results.""" return self._manager.list_previous_page() def list_next_page(self): """Returns the next page of results.""" return self._manager.list_next_page() def get_domain_iterator(self): """ Returns an iterator that will return each available domain. If there are more than the limit of 100 domains, the iterator will continue to fetch domains from the API until all domains have been returned. """ return DomainResultsIterator(self._manager) @assure_domain def changes_since(self, domain, date_or_datetime): """ Gets the changes for a domain since the specified date/datetime. The date can be one of: - a Python datetime object - a Python date object - a string in the format 'YYYY-MM-YY HH:MM:SS' - a string in the format 'YYYY-MM-YY' It returns a list of dicts, whose keys depend on the specific change that was made. A simple example of such a change dict: {u'accountId': 000000, u'action': u'update', u'changeDetails': [{u'field': u'serial_number', u'newValue': u'1354038941', u'originalValue': u'1354038940'}, {u'field': u'updated_at', u'newValue': u'Tue Nov 27 17:55:41 UTC 2012', u'originalValue': u'Tue Nov 27 17:55:40 UTC 2012'}], u'domain': u'example.com', u'targetId': 00000000, u'targetType': u'Domain'} """ return domain.changes_since(date_or_datetime) @assure_domain def export_domain(self, domain): """ Provides the BIND (Berkeley Internet Name Domain) 9 formatted contents of the requested domain. This call is for a single domain only, and as such, does not provide subdomain information. Sample export: {u'accountId': 000000, u'contentType': u'BIND_9', u'contents': u'example.com.\t3600\tIN\tSOA\tns.rackspace.com. ' 'foo@example.com. 1354202974 21600 3600 1814400 500' 'example.com.\t3600\tIN\tNS\tdns1.stabletransit.com.' 'example.com.\t3600\tIN\tNS\tdns2.stabletransit.com.', u'id': 1111111} """ return domain.export() def import_domain(self, domain_data): """ Takes a string in the BIND 9 format and creates a new domain. See the 'export_domain()' method for a description of the format. """ return self._manager.import_domain(domain_data) @assure_domain def update_domain(self, domain, emailAddress=None, ttl=None, comment=None): """ Provides a way to modify the following attributes of a domain record: - email address - ttl setting - comment """ return domain.update(emailAddress=emailAddress, ttl=ttl, comment=comment) @assure_domain def delete(self, domain, delete_subdomains=False): """ Deletes the specified domain and all of its resource records. If the domain has subdomains, each subdomain will now become a root domain. If you wish to also delete any subdomains, pass True to 'delete_subdomains'. """ domain.delete(delete_subdomains=delete_subdomains) @assure_domain def list_subdomains(self, domain, limit=None, offset=None): """ Returns a list of all subdomains for the specified domain. """ return domain.list_subdomains(limit=limit, offset=offset) def get_subdomain_iterator(self, domain, limit=None, offset=None): """ Returns an iterator that will return each available subdomain for the specified domain. If there are more than the limit of 100 subdomains, the iterator will continue to fetch subdomains from the API until all subdomains have been returned. """ return SubdomainResultsIterator(self._manager, domain=domain) def list_subdomains_previous_page(self): """Returns the previous page of subdomain results.""" return self._manager.list_subdomains_previous_page() def list_subdomains_next_page(self): """Returns the next page of subdomain results.""" return self._manager.list_subdomains_next_page() @assure_domain def list_records(self, domain, limit=None, offset=None): """ Returns a list of all records configured for the specified domain. """ return domain.list_records(limit=limit, offset=offset) def get_record_iterator(self, domain): """ Returns an iterator that will return each available DNS record for the specified domain. If there are more than the limit of 100 records, the iterator will continue to fetch records from the API until all records have been returned. """ return RecordResultsIterator(self._manager, domain=domain) def list_records_previous_page(self): """Returns the previous page of record results.""" return self._manager.list_records_previous_page() def list_records_next_page(self): """Returns the next page of record results.""" return self._manager.list_records_next_page() @assure_domain def search_records(self, domain, record_type, name=None, data=None): """ Returns a list of all records configured for the specified domain that match the supplied search criteria. """ return domain.search_records(record_type=record_type, name=name, data=data) @assure_domain def find_record(self, domain, record_type, name=None, data=None): """ Returns a single record for this domain that matches the supplied search criteria. If no record matches, a DomainRecordNotFound exception will be raised. If more than one matches, a DomainRecordNotUnique exception will be raised. """ return domain.find_record(record_type=record_type, name=name, data=data) @assure_domain def add_records(self, domain, records): """ Adds the records to this domain. Each record should be a dict with the following keys: - type (required) - name (required) - data (required) - ttl (optional) - comment (optional) - priority (required for MX and SRV records; forbidden otherwise) """ return domain.add_records(records) # Create an alias, so that adding a single record is more intuitive add_record = add_records @assure_domain def get_record(self, domain, record): """ Gets the full information for an existing record or record ID for the specified domain. """ return domain.get_record(record) @assure_domain def update_record(self, domain, record, data=None, priority=None, ttl=None, comment=None): """ Modifies an existing record for a domain. """ return domain.update_record(record, data=data, priority=priority, ttl=ttl, comment=comment) @assure_domain def delete_record(self, domain, record): """ Deletes an existing record for this domain. """ return domain.delete_record(record) def list_ptr_records(self, device): """ Returns a list of all PTR records configured for this device. """ return self._manager.list_ptr_records(device) def add_ptr_records(self, device, records): """ Adds one or more PTR records to the specified device. """ return self._manager.add_ptr_records(device, records) def update_ptr_record(self, device, record, domain_name, data=None, ttl=None, comment=None): """ Updates a PTR record with the supplied values. """ return self._manager.update_ptr_record(device, record, domain_name, data=data, ttl=ttl, comment=comment) def delete_ptr_records(self, device, ip_address=None): """ Deletes the PTR records for the specified device. If 'ip_address' is supplied, only the PTR records with that IP address will be deleted. """ return self._manager.delete_ptr_records(device, ip_address=ip_address) def get_absolute_limits(self): """ Returns a dict with the absolute limits for the current account. """ resp, body = self.method_get("/limits") absolute_limits = body.get("limits", {}).get("absolute") return absolute_limits def get_rate_limits(self): """ Returns a dict with the current rate limit information for domain and status requests. """ resp, body = self.method_get("/limits") rate_limits = body.get("limits", {}).get("rate") ret = [] for rate_limit in rate_limits: limits = rate_limit["limit"] uri_limits = {"uri": rate_limit["uri"], "limits": limits} ret.append(uri_limits) return ret class ResultsIterator(object): """ This object will iterate over all the results for a given type of listing, no matter how many items exist. This is an abstract class; subclasses must define the _init_methods() method. """ def __init__(self, manager, domain=None): self.manager = manager self.domain = domain self.domain_id = utils.get_id(domain) if domain else None self.results = [] self.next_uri = "" self.extra_args = tuple() self._init_methods() def _init_methods(self): """ Must be implemented in subclasses. """ raise NotImplementedError() def __iter__(self): return self def next(self): """ Return the next available item. If there are no more items in the local 'results' list, check if there is a 'next_uri' value. If so, use that to get the next page of results from the API, and return the first item from that query. """ try: return self.results.pop(0) except IndexError: if self.next_uri is None: raise StopIteration() else: if not self.next_uri: if self.domain: self.results = self.list_method(self.domain) else: self.results = self.list_method() else: args = self.extra_args self.results = self._list_method(self.next_uri, *args) self.next_uri = self.manager._paging.get( self.paging_service, {}).get("next_uri") # We should have more results. try: return self.results.pop(0) except IndexError: raise StopIteration() class DomainResultsIterator(ResultsIterator): """ ResultsIterator subclass for iterating over all domains. """ def _init_methods(self): self.list_method = self.manager.list self._list_method = self.manager._list self.paging_service = "domain" class SubdomainResultsIterator(ResultsIterator): """ ResultsIterator subclass for iterating over all subdomains. """ def _init_methods(self): self.list_method = self.manager.list_subdomains self._list_method = self.manager._list_subdomains self.extra_args = (self.domain_id, ) self.paging_service = "subdomain" class RecordResultsIterator(ResultsIterator): """ ResultsIterator subclass for iterating over all domain records. """ def _init_methods(self): self.list_method = self.manager.list_records self._list_method = self.manager._list_records self.paging_service = "record"