663 lines
24 KiB
Python
663 lines
24 KiB
Python
# 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)
|