Просмотр исходного кода

Merge pull request #211 from nuwang/dnssupport

[WIP] Dns support for cloudbridge
Nuwan Goonasekera 6 лет назад
Родитель
Сommit
442ace681d

+ 17 - 1
cloudbridge/base/helpers.py

@@ -10,7 +10,7 @@ from cryptography.hazmat.backends import default_backend
 from cryptography.hazmat.primitives import serialization as crypt_serialization
 from cryptography.hazmat.primitives import serialization as crypt_serialization
 from cryptography.hazmat.primitives.asymmetric import rsa
 from cryptography.hazmat.primitives.asymmetric import rsa
 
 
-from deprecation import deprecated
+from deprecated import deprecated
 
 
 import six
 import six
 
 
@@ -165,3 +165,19 @@ def rename_kwargs(func_name, kwargs, aliases):
                        details='{} is deprecated, use {} instead'.format(
                        details='{} is deprecated, use {} instead'.format(
                            alias, new))(lambda: None)()
                            alias, new))(lambda: None)()
             kwargs[new] = kwargs.pop(alias)
             kwargs[new] = kwargs.pop(alias)
+
+
+NON_ALPHA_NUM = re.compile(r"[^A-Za-z0-9]+")
+
+
+def to_resource_name(value, replace_with="-"):
+    """
+    Converts a given string to a valid resource name by stripping
+    all characters that are not alphanumeric.
+
+    :param value: the value to strip
+    :param replace_with: the value to replace mismatching characters with
+    :return: a string with all mismatching characters removed.
+    """
+    val = re.sub(NON_ALPHA_NUM, replace_with, value)
+    return val.strip("-")

+ 73 - 1
cloudbridge/base/resources.py

@@ -21,6 +21,8 @@ from cloudbridge.interfaces.resources import AttachmentInfo
 from cloudbridge.interfaces.resources import Bucket
 from cloudbridge.interfaces.resources import Bucket
 from cloudbridge.interfaces.resources import BucketObject
 from cloudbridge.interfaces.resources import BucketObject
 from cloudbridge.interfaces.resources import CloudResource
 from cloudbridge.interfaces.resources import CloudResource
+from cloudbridge.interfaces.resources import DnsRecord
+from cloudbridge.interfaces.resources import DnsZone
 from cloudbridge.interfaces.resources import FloatingIP
 from cloudbridge.interfaces.resources import FloatingIP
 from cloudbridge.interfaces.resources import FloatingIpState
 from cloudbridge.interfaces.resources import FloatingIpState
 from cloudbridge.interfaces.resources import GatewayState
 from cloudbridge.interfaces.resources import GatewayState
@@ -872,7 +874,6 @@ class BaseInternetGateway(BaseCloudResource, BaseObjectLifeCycleMixin,
 
 
     def __init__(self, provider):
     def __init__(self, provider):
         super(BaseInternetGateway, self).__init__(provider)
         super(BaseInternetGateway, self).__init__(provider)
-        self.__provider = provider
 
 
     def __eq__(self, other):
     def __eq__(self, other):
         return (isinstance(other, InternetGateway) and
         return (isinstance(other, InternetGateway) and
@@ -890,3 +891,74 @@ class BaseInternetGateway(BaseCloudResource, BaseObjectLifeCycleMixin,
     def delete(self):
     def delete(self):
         return self._provider.networking._gateways.delete(self.network_id,
         return self._provider.networking._gateways.delete(self.network_id,
                                                           self)
                                                           self)
+
+
+class BaseDnsZone(BaseCloudResource, DnsZone):
+
+    CB_NAME_PATTERN = re.compile(
+        r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]"
+        r"[a-z0-9-]{0,61}[a-z0-9]\.?$")
+
+    def __init__(self, provider):
+        super(BaseDnsZone, self).__init__(provider)
+
+    def __eq__(self, other):
+        return (isinstance(other, BaseDnsZone) and
+                # pylint:disable=protected-access
+                self._provider == other._provider and
+                self.id == other.id)
+
+    @staticmethod
+    def is_valid_resource_name(name):
+        if not name:
+            return False
+        else:
+            return (True if BaseDnsZone.CB_NAME_PATTERN.match(name)
+                    else False)
+
+    @staticmethod
+    def assert_valid_resource_name(name):
+        if not BaseDnsZone.is_valid_resource_name(name):
+            log.debug("InvalidNameException raised on %s", name,
+                      exc_info=True)
+            raise InvalidNameException(
+                u"Invalid object name: %s. Name must be fully qualified "
+                u"(ending with a .) and match criteria defined "
+                u"in: https://stackoverflow.com/q/10306690/10971151" % name)
+
+    def delete(self):
+        return self._provider.dns.host_zones.delete(self.id)
+
+
+class BaseDnsRecord(BaseCloudResource, DnsRecord):
+
+    CB_NAME_PATTERN = re.compile(
+        r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]"
+        r"[a-z0-9-]{0,61}[a-z0-9]\.?$")
+
+    def __init__(self, provider):
+        super(BaseDnsRecord, self).__init__(provider)
+
+    def __eq__(self, other):
+        return (isinstance(other, BaseDnsRecord) and
+                # pylint:disable=protected-access
+                self._provider == other._provider and
+                self.id == other.id)
+
+    @staticmethod
+    def is_valid_resource_name(name):
+        if not name:
+            return False
+        else:
+            return (True if BaseDnsRecord.CB_NAME_PATTERN.match(name)
+                    else False)
+
+    @staticmethod
+    def assert_valid_resource_name(name):
+        if not BaseDnsRecord.is_valid_resource_name(name):
+            log.debug("InvalidNameException raised on %s", name,
+                      exc_info=True)
+            raise InvalidNameException(
+                u"Invalid object name: %s. Name must be fully qualified "
+                u"(ending with a .) and match criteria defined "
+                u"in: https://stackoverflow.com/q/10306690/10971151" % name)

+ 36 - 10
cloudbridge/base/services.py

@@ -4,11 +4,15 @@ Base implementation for services available through a provider
 import logging
 import logging
 
 
 from cloudbridge.interfaces.exceptions import InvalidParamException
 from cloudbridge.interfaces.exceptions import InvalidParamException
+from cloudbridge.interfaces.resources import DnsRecordType
 from cloudbridge.interfaces.resources import Network
 from cloudbridge.interfaces.resources import Network
 from cloudbridge.interfaces.services import BucketObjectService
 from cloudbridge.interfaces.services import BucketObjectService
 from cloudbridge.interfaces.services import BucketService
 from cloudbridge.interfaces.services import BucketService
 from cloudbridge.interfaces.services import CloudService
 from cloudbridge.interfaces.services import CloudService
 from cloudbridge.interfaces.services import ComputeService
 from cloudbridge.interfaces.services import ComputeService
+from cloudbridge.interfaces.services import DnsRecordService
+from cloudbridge.interfaces.services import DnsService
+from cloudbridge.interfaces.services import DnsZoneService
 from cloudbridge.interfaces.services import FloatingIPService
 from cloudbridge.interfaces.services import FloatingIPService
 from cloudbridge.interfaces.services import GatewayService
 from cloudbridge.interfaces.services import GatewayService
 from cloudbridge.interfaces.services import ImageService
 from cloudbridge.interfaces.services import ImageService
@@ -343,22 +347,12 @@ class BaseGatewayService(GatewayService, BaseCloudService):
 
 
     def __init__(self, provider):
     def __init__(self, provider):
         super(BaseGatewayService, self).__init__(provider)
         super(BaseGatewayService, self).__init__(provider)
-        self._provider = provider
-
-    @property
-    def provider(self):
-        return self._provider
 
 
 
 
 class BaseFloatingIPService(FloatingIPService, BaseCloudService):
 class BaseFloatingIPService(FloatingIPService, BaseCloudService):
 
 
     def __init__(self, provider):
     def __init__(self, provider):
         super(BaseFloatingIPService, self).__init__(provider)
         super(BaseFloatingIPService, self).__init__(provider)
-        self._provider = provider
-
-    @property
-    def provider(self):
-        return self._provider
 
 
     @dispatch(event="provider.networking.floating_ips.find",
     @dispatch(event="provider.networking.floating_ips.find",
               priority=BaseCloudService.STANDARD_EVENT_PRIORITY)
               priority=BaseCloudService.STANDARD_EVENT_PRIORITY)
@@ -367,3 +361,35 @@ class BaseFloatingIPService(FloatingIPService, BaseCloudService):
         filters = ['name', 'public_ip']
         filters = ['name', 'public_ip']
         matches = cb_helpers.generic_find(filters, kwargs, obj_list)
         matches = cb_helpers.generic_find(filters, kwargs, obj_list)
         return ClientPagedResultList(self._provider, list(matches))
         return ClientPagedResultList(self._provider, list(matches))
+
+
+class BaseDnsService(DnsService, BaseCloudService):
+
+    def __init__(self, provider):
+        super(BaseDnsService, self).__init__(provider)
+
+
+class BaseDnsZoneService(BasePageableObjectMixin, DnsZoneService,
+                         BaseCloudService):
+
+    def __init__(self, provider):
+        super(BaseDnsZoneService, self).__init__(provider)
+
+    def _get_fully_qualified_dns(self, name):
+        # Add a trailing dot to fully qualify
+        return name + '.' if not name.endswith('.') else name
+
+
+class BaseDnsRecordService(BasePageableObjectMixin, DnsRecordService,
+                           BaseCloudService):
+
+    def __init__(self, provider):
+        super(BaseDnsRecordService, self).__init__(provider)
+
+    def _get_fully_qualified_dns(self, name):
+        # Add a trailing dot to fully qualify
+        return name + '.' if not name.endswith('.') else name
+
+    def _standardize_record(self, value, type):
+        return (self._get_fully_qualified_dns(value)
+                if type in (DnsRecordType.CNAME, DnsRecordType.MX) else value)

+ 34 - 0
cloudbridge/base/subservices.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 
 
 from cloudbridge.interfaces.subservices import BucketObjectSubService
 from cloudbridge.interfaces.subservices import BucketObjectSubService
+from cloudbridge.interfaces.subservices import DnsRecordSubService
 from cloudbridge.interfaces.subservices import FloatingIPSubService
 from cloudbridge.interfaces.subservices import FloatingIPSubService
 from cloudbridge.interfaces.subservices import GatewaySubService
 from cloudbridge.interfaces.subservices import GatewaySubService
 from cloudbridge.interfaces.subservices import SubnetSubService
 from cloudbridge.interfaces.subservices import SubnetSubService
@@ -166,3 +167,36 @@ class BaseSubnetSubService(SubnetSubService, BasePageableObjectMixin):
 
 
     def delete(self, subnet):
     def delete(self, subnet):
         return self._provider.networking.subnets.delete(subnet)
         return self._provider.networking.subnets.delete(subnet)
+
+
+class BaseDnsRecordSubService(DnsRecordSubService, BasePageableObjectMixin):
+
+    def __init__(self, provider, dns_zone):
+        self.__provider = provider
+        self.dns_zone = dns_zone
+
+    @property
+    def _provider(self):
+        return self.__provider
+
+    def get(self, rec_id):
+        # pylint:disable=protected-access
+        return self._provider.dns._records.get(self.dns_zone, rec_id)
+
+    def list(self, limit=None, marker=None):
+        # pylint:disable=protected-access
+        return self._provider.dns._records.list(
+            dns_zone=self.dns_zone, limit=limit, marker=marker)
+
+    def find(self, **kwargs):
+        # pylint:disable=protected-access
+        return self._provider.dns._records.find(
+            dns_zone=self.dns_zone, **kwargs)
+
+    def create(self, name, type, data, ttl=None):
+        # pylint:disable=protected-access
+        return self._provider.dns._records.create(
+            self.dns_zone, name, type, data, ttl)
+
+    def delete(self, rec):
+        return self._provider.dns._records.delete(self.dns_zone, rec)

+ 1 - 1
cloudbridge/interfaces/exceptions.py

@@ -75,7 +75,7 @@ class InvalidLabelException(InvalidNameException):
 class InvalidValueException(CloudBridgeBaseException):
 class InvalidValueException(CloudBridgeBaseException):
     """
     """
     Marker interface for any attempt to set an invalid value on a CloudBridge
     Marker interface for any attempt to set an invalid value on a CloudBridge
-    resource.An example would be setting an unrecognised value for the
+    resource. An example would be setting an unrecognised value for the
     direction of a firewall rule other than TrafficDirection.INBOUND or
     direction of a firewall rule other than TrafficDirection.INBOUND or
     TrafficDirection.OUTBOUND.
     TrafficDirection.OUTBOUND.
     """
     """

+ 19 - 0
cloudbridge/interfaces/provider.py

@@ -232,6 +232,25 @@ class CloudProvider(object):
         """
         """
         pass
         pass
 
 
+    @abstractproperty
+    def dns(self):
+        """
+        Provides access to all DNS related services.
+
+        Example:
+
+        .. code-block:: python
+
+            if provider.has_service(CloudServiceType.DNS):
+               print("Provider supports DNS services")
+               dns_zones = provider.dns.host_zones.list()
+               print(dns_zones)
+
+        :rtype: :class:`.DnsService`
+        :return: a DNS service object
+        """
+        pass
+
 
 
 class TestMockHelperMixin(object):
 class TestMockHelperMixin(object):
     """
     """

+ 106 - 0
cloudbridge/interfaces/resources.py

@@ -24,6 +24,7 @@ class CloudServiceType(object):
     SECURITY = 'security'
     SECURITY = 'security'
     VOLUME = 'storage.volumes'
     VOLUME = 'storage.volumes'
     BUCKET = 'storage.buckets'
     BUCKET = 'storage.buckets'
+    DNS = 'dns'
 
 
 
 
 class CloudResource(object):
 class CloudResource(object):
@@ -1306,6 +1307,111 @@ class InternetGateway(ObjectLifeCycleMixin, Gateway):
     __metaclass__ = ABCMeta
     __metaclass__ = ABCMeta
 
 
 
 
+class DnsZone(CloudResource):
+    """
+    Represents a dns host zone.
+
+    A host zone represents a top level domain (e.g. cloudve.org) in which
+    multiple dns records (e.g. A, CNAME. MX etc.) are contained.
+    """
+    __metaclass__ = ABCMeta
+
+    @property
+    def admin_email(self):
+        """
+        Email address of this zone's administrator. Some cloud providers do not
+        support this field, and therefore, it may be stored in an extra field
+        such as description or not supported at all. (This field is mandatory
+        in OpenStack)
+
+        :return: Administrator's email as a string
+        """
+        pass
+
+    @abstractmethod
+    def delete(self):
+        """
+        Delete this zone.
+        """
+        pass
+
+    @abstractproperty
+    def records(self):
+        """
+        List of DNS records in this zone.
+
+        :rtype: ``list`` of :class:`.DnsRecord` objects
+        :return: A list of DnsRecords associated with this zone.
+        """
+        pass
+
+
+class DnsRecordType(object):
+    """
+    DNS record types.
+    """
+    A = 'A'
+    AAAA = 'AAAA'
+    CNAME = 'CNAME'
+    MX = 'MX'
+    NS = 'NS'
+    PTR = 'PTR'
+    SPF = 'SPF'
+    SRV = 'SRV'
+    SSHFP = 'SSHFP'
+    TXT = 'TXT'
+
+
+class DnsRecord(CloudResource):
+    """
+    Represents a dns record.
+
+    A dns record belongs to a host zone and can contain
+    records of varous types such as A, CNAME. MX etc.
+    """
+    __metaclass__ = ABCMeta
+
+    @abstractproperty
+    def zone_id(self):
+        """
+        The containing zone for this dns record
+
+        :rtype: ``str``
+        :return: The ID of the zone for this dns record
+        """
+        pass
+
+    @abstractproperty
+    def type(self):
+        """
+        Dns Record type which could be A, CNAME, MX, AAAA, PTR
+
+        :rtype: ``DnsRecordType``
+        :return: An enum representing the DNS record type.
+        """
+        pass
+
+    @abstractproperty
+    def data(self):
+        """
+        Dns Record data
+
+        :rtype: ``str``
+        :return: A string containing this DNS record's data.
+        """
+        pass
+
+    @abstractproperty
+    def ttl(self):
+        """
+        ttl for this record
+
+        :rtype: ``int``
+        :return: The ttl (in seconds) for this record.
+        """
+        pass
+
+
 class AttachmentInfo(object):
 class AttachmentInfo(object):
     """
     """
     Contains attachment information for a volume.
     Contains attachment information for a volume.

+ 156 - 0
cloudbridge/interfaces/services.py

@@ -873,6 +873,162 @@ class RouterService(PageableObjectMixin, CloudService):
         pass
         pass
 
 
 
 
+class DnsService(CloudService):
+    """
+    Base service interface for DNS.
+
+    This service offers a collection of DNS services that in turn
+    provide access to DNS resources.
+    """
+    __metaclass__ = ABCMeta
+
+    @abstractproperty
+    def host_zones(self):
+        """
+        Provides access to all dns zones.
+
+        :rtype: :class:`.DnsZoneService`
+        :return: a Dns Host Zone service
+        """
+        pass
+
+    @abstractproperty
+    def _records(self):
+        """
+        Provides access to dns records for this service.
+        This service is not iterable.
+
+        :rtype: :class:`.DnsRecordSubService`
+        :return: a DnsRecordSubService object
+        """
+        pass
+
+
+class DnsZoneService(PageableObjectMixin, CloudService):
+    """
+    Manage DNS Zone actions and resources. This service is optional and
+    the :func:`CloudProvider.has_service()` method should be used to verify its
+    availability before using the service.
+    """
+    __metaclass__ = ABCMeta
+
+    @abstractmethod
+    def get(self, dns_zone_id):
+        """
+        Returns a DnsZone object given its ID.
+
+        :type dns_zone_id: ``str``
+        :param dns_zone_id: The ID of the host zone to retrieve.
+
+        :rtype: ``object``  of :class:`.DnsZone` or ``None``
+        :return: a DnsZone object of ``None`` if not found.
+        """
+        pass
+
+    @abstractmethod
+    def list(self, limit=None, marker=None):
+        """
+        List all host zones.
+
+        :rtype: ``list`` of :class:`.DnsZone`
+        :return: list of DnsZone objects
+        """
+        pass
+
+    @abstractmethod
+    def find(self, **kwargs):
+        """
+        Searches for a host zone by a given list of attributes.
+
+        Supported attributes: label
+
+        :rtype: List of ``object`` of :class:`.DnsZone`
+        :return: A list of Dns Zone objects matching the supplied attributes.
+        """
+        pass
+
+    @abstractmethod
+    def create(self, label, admin_email):
+        """
+        Create a new host zone.
+
+        :type label: ``str``
+        :param label: A host zone label.
+
+        :type admin_email: ``str``
+        :param admin_email: Email address of this zone's administrator.
+
+        :rtype: ``object`` of :class:`.DnsZone`
+        :return:  A DnsZone object
+        """
+        pass
+
+    @abstractmethod
+    def delete(self, dns_zone):
+        """
+        Delete an existing DnsHostZone.
+
+        :type dns_zone: :class:`.DnsZone` object or ``str``
+        :param dns_zone: DnsZone object or ID of the host zone to delete.
+        """
+        pass
+
+
+class DnsRecordService(CloudService):
+
+    """
+    The Dns Record Service interface provides access to the records belonging
+    to a Dns Zone.
+    """
+    __metaclass__ = ABCMeta
+
+    @abstractmethod
+    def get(self, dns_zone, rec_id):
+        """
+        Returns a record given its ID and the dns_zone containing
+        it. Returns ``None`` if the record does not exist.
+
+        :rtype: :class:`.DnsRecord`
+        :return:  a DnsRecord instance
+        """
+        pass
+
+    @abstractmethod
+    def list(self, dns_zone, limit=None, marker=None):
+        """
+        List all records within a dns zone.
+
+        :rtype: :class:`.DnsRecord`
+        :return:  a DnsRecord instance
+        """
+        pass
+
+    @abstractmethod
+    def create(self, dns_zone, name, type, data, ttl=None):
+        """
+        Create a new record within a zone.
+
+        :type name: ``str``
+        :param name: The record name.
+
+        :type type: ``str``
+        :param type: The DnsRecord type. (e.g. A, CNAME, MX etc)
+
+        :type data: ``str``
+        :param data: The corresponding value for the record. The relevant
+                     values must be fully qualified (e.g. CNAMEs). If the
+                     trailing dot is omitted, it will be automatically
+                     added and thus assumed to be fully qualified.
+
+        :type data: ``int``
+        :param data: The ttl (in seconds) for this record.
+
+        :rtype: ``object`` of :class:`.DnsRecord`
+        :return:  A DnsRecord object
+        """
+        pass
+
+
 class BucketService(PageableObjectMixin, CloudService):
 class BucketService(PageableObjectMixin, CloudService):
 
 
     """
     """

+ 83 - 1
cloudbridge/interfaces/subservices.py

@@ -222,7 +222,7 @@ class VMFirewallRuleSubService(PageableObjectMixin):
         pass
         pass
 
 
     @abstractmethod
     @abstractmethod
-    def create(self,  direction, protocol=None, from_port=None,
+    def create(self, direction, protocol=None, from_port=None,
                to_port=None, cidr=None, src_dest_fw=None):
                to_port=None, cidr=None, src_dest_fw=None):
         """
         """
         Create a VM firewall rule.
         Create a VM firewall rule.
@@ -395,3 +395,85 @@ class SubnetSubService(PageableObjectMixin):
         :param subnet_id: The ID of the Subnet to be deleted.
         :param subnet_id: The ID of the Subnet to be deleted.
         """
         """
         pass
         pass
+
+
+class DnsRecordSubService(PageableObjectMixin):
+    """
+    Base interface for a Dns Record Service.
+    """
+    __metaclass__ = ABCMeta
+
+    @abstractmethod
+    def get(self, record_id):
+        """
+        Returns a Dns Record given its ID or ``None`` if not found.
+
+        :type record_id: ``str``
+        :param record_id: The ID of the DnsRecord to retrieve.
+
+        :rtype: ``object`` of :class:`.DnsRecord`
+        :return: a DnsRecord object
+        """
+        pass
+
+    @abstractmethod
+    def list(self, limit=None, marker=None):
+        """
+        List Dns Records within the Dns Zone holding this subservice.
+
+        :rtype: ``list`` of :class:`.DnsRecord`
+        :return: list of DnsRecord objects
+        """
+        pass
+
+    @abstractmethod
+    def find(self, **kwargs):
+        """
+        Searches for a DnsRecord by a given list of attributes.
+
+        Supported attributes: label
+
+        Example:
+
+        .. code-block:: python
+
+            subnet = provider.networking.dns.host_zones.get('id').records.find(
+                        label='my-label')
+
+
+        :rtype: List of ``object`` of :class:`.DnsRecord`
+        :return: A list of DnsRecord objects matching the supplied attributes.
+        """
+        pass
+
+    @abstractmethod
+    def create(self, label, type, data, ttl=None):
+        """
+        Create a new DnsRecord within the Dns Zone holding this subservice.
+
+        :type label: ``str``
+        :param label: The record label.
+
+        :type type: ``str``
+        :param type: The DnsRecord type. (e.g. A, CNAME, MX etc)
+
+        :type data: ``str``
+        :param data: The corresponding value for the record.
+
+        :type data: ``int``
+        :param data: The ttl (in seconds) for thisrecord.
+
+        :rtype: ``object`` of :class:`.DnsRecord`
+        :return:  A DnsRecord object
+        """
+        pass
+
+    @abstractmethod
+    def delete(self, record_id):
+        """
+        Delete an existing DnsRecord.
+
+        :type record_id: ``str``
+        :param record_id: The ID of the DnsRecord to be deleted.
+        """
+        pass

+ 6 - 0
cloudbridge/providers/aws/provider.py

@@ -7,6 +7,7 @@ from cloudbridge.base import BaseCloudProvider
 from cloudbridge.base.helpers import get_env
 from cloudbridge.base.helpers import get_env
 
 
 from .services import AWSComputeService
 from .services import AWSComputeService
+from .services import AWSDnsService
 from .services import AWSNetworkingService
 from .services import AWSNetworkingService
 from .services import AWSSecurityService
 from .services import AWSSecurityService
 from .services import AWSStorageService
 from .services import AWSStorageService
@@ -55,6 +56,7 @@ class AWSCloudProvider(BaseCloudProvider):
         self._networking = AWSNetworkingService(self)
         self._networking = AWSNetworkingService(self)
         self._security = AWSSecurityService(self)
         self._security = AWSSecurityService(self)
         self._storage = AWSStorageService(self)
         self._storage = AWSStorageService(self)
+        self._dns = AWSDnsService(self)
 
 
     @property
     @property
     def session(self):
     def session(self):
@@ -94,6 +96,10 @@ class AWSCloudProvider(BaseCloudProvider):
     def storage(self):
     def storage(self):
         return self._storage
         return self._storage
 
 
+    @property
+    def dns(self):
+        return self._dns
+
     def _connect_ec2(self):
     def _connect_ec2(self):
         """
         """
         Get a boto ec2 connection object.
         Get a boto ec2 connection object.

+ 85 - 0
cloudbridge/providers/aws/resources.py

@@ -10,6 +10,8 @@ from botocore.exceptions import ClientError
 from cloudbridge.base.resources import BaseAttachmentInfo
 from cloudbridge.base.resources import BaseAttachmentInfo
 from cloudbridge.base.resources import BaseBucket
 from cloudbridge.base.resources import BaseBucket
 from cloudbridge.base.resources import BaseBucketObject
 from cloudbridge.base.resources import BaseBucketObject
+from cloudbridge.base.resources import BaseDnsRecord
+from cloudbridge.base.resources import BaseDnsZone
 from cloudbridge.base.resources import BaseFloatingIP
 from cloudbridge.base.resources import BaseFloatingIP
 from cloudbridge.base.resources import BaseInstance
 from cloudbridge.base.resources import BaseInstance
 from cloudbridge.base.resources import BaseInternetGateway
 from cloudbridge.base.resources import BaseInternetGateway
@@ -38,6 +40,7 @@ from cloudbridge.interfaces.resources import VolumeState
 from .helpers import find_tag_value
 from .helpers import find_tag_value
 from .helpers import trim_empty_params
 from .helpers import trim_empty_params
 from .subservices import AWSBucketObjectSubService
 from .subservices import AWSBucketObjectSubService
+from .subservices import AWSDnsRecordSubService
 from .subservices import AWSFloatingIPSubService
 from .subservices import AWSFloatingIPSubService
 from .subservices import AWSGatewaySubService
 from .subservices import AWSGatewaySubService
 from .subservices import AWSSubnetSubService
 from .subservices import AWSSubnetSubService
@@ -1138,3 +1141,85 @@ class AWSLaunchConfig(BaseLaunchConfig):
 
 
     def __init__(self, provider):
     def __init__(self, provider):
         super(AWSLaunchConfig, self).__init__(provider)
         super(AWSLaunchConfig, self).__init__(provider)
+
+
+class AWSDnsZone(BaseDnsZone):
+
+    def __init__(self, provider, dns_zone):
+        super(AWSDnsZone, self).__init__(provider)
+        self._dns_zone = dns_zone
+        self._dns_record_container = AWSDnsRecordSubService(provider, self)
+
+    @property
+    def id(self):
+        # The ID contains a slash, do not allow this
+        return self.escape_zone_id(self.aws_id)
+
+    @property
+    def aws_id(self):
+        return self._dns_zone.get('Id')
+
+    @staticmethod
+    def escape_zone_id(value):
+        return value.replace("/", "-") if value else None
+
+    @staticmethod
+    def unescape_zone_id(value):
+        return value.replace("-", "/") if value else None
+
+    @property
+    def name(self):
+        return self._dns_zone.get('Name')
+
+    @property
+    def admin_email(self):
+        comment = self._dns_zone.get('Config', {}).get('Comment')
+        if comment:
+            email_field = comment.split(",")[0].split("=")
+            if email_field[0] == "admin_email":
+                return email_field[1]
+            else:
+                return None
+        else:
+            return None
+
+    @property
+    def records(self):
+        return self._dns_record_container
+
+
+class AWSDnsRecord(BaseDnsRecord):
+
+    def __init__(self, provider, dns_zone, dns_record):
+        super(AWSDnsRecord, self).__init__(provider)
+        self._dns_zone = dns_zone
+        self._dns_rec = dns_record
+
+    @property
+    def id(self):
+        return self._dns_rec.get('Name') + ":" + self._dns_rec.get('Type')
+
+    @property
+    def name(self):
+        return self._dns_rec.get('Name')
+
+    @property
+    def zone_id(self):
+        return self._dns_zone.id
+
+    @property
+    def type(self):
+        return self._dns_rec.get('Type')
+
+    @property
+    def data(self):
+        return [rec.get('Value') for rec in
+                self._dns_rec.get('ResourceRecords')]
+
+    @property
+    def ttl(self):
+        return self._dns_rec.get('TTL')
+
+    def delete(self):
+        # pylint:disable=protected-access
+        return self._provider.dns._records.delete(self._dns_zone, self)

+ 199 - 0
cloudbridge/providers/aws/services.py

@@ -2,6 +2,7 @@
 import ipaddress
 import ipaddress
 import logging
 import logging
 import string
 import string
+import uuid
 
 
 from botocore.exceptions import ClientError
 from botocore.exceptions import ClientError
 
 
@@ -12,9 +13,13 @@ import requests
 import cloudbridge.base.helpers as cb_helpers
 import cloudbridge.base.helpers as cb_helpers
 from cloudbridge.base.middleware import dispatch
 from cloudbridge.base.middleware import dispatch
 from cloudbridge.base.resources import ClientPagedResultList
 from cloudbridge.base.resources import ClientPagedResultList
+from cloudbridge.base.resources import ServerPagedResultList
 from cloudbridge.base.services import BaseBucketObjectService
 from cloudbridge.base.services import BaseBucketObjectService
 from cloudbridge.base.services import BaseBucketService
 from cloudbridge.base.services import BaseBucketService
 from cloudbridge.base.services import BaseComputeService
 from cloudbridge.base.services import BaseComputeService
+from cloudbridge.base.services import BaseDnsRecordService
+from cloudbridge.base.services import BaseDnsService
+from cloudbridge.base.services import BaseDnsZoneService
 from cloudbridge.base.services import BaseFloatingIPService
 from cloudbridge.base.services import BaseFloatingIPService
 from cloudbridge.base.services import BaseGatewayService
 from cloudbridge.base.services import BaseGatewayService
 from cloudbridge.base.services import BaseImageService
 from cloudbridge.base.services import BaseImageService
@@ -51,6 +56,8 @@ from .helpers import BotoS3Service
 from .helpers import trim_empty_params
 from .helpers import trim_empty_params
 from .resources import AWSBucket
 from .resources import AWSBucket
 from .resources import AWSBucketObject
 from .resources import AWSBucketObject
+from .resources import AWSDnsRecord
+from .resources import AWSDnsZone
 from .resources import AWSFloatingIP
 from .resources import AWSFloatingIP
 from .resources import AWSInstance
 from .resources import AWSInstance
 from .resources import AWSInternetGateway
 from .resources import AWSInternetGateway
@@ -1300,3 +1307,195 @@ class AWSFloatingIPService(BaseFloatingIPService):
         else:
         else:
             aws_fip = self.svc.get_raw(fip)
             aws_fip = self.svc.get_raw(fip)
         aws_fip.release()
         aws_fip.release()
+
+
+class AWSDnsService(BaseDnsService):
+
+    def __init__(self, provider):
+        super(AWSDnsService, self).__init__(provider)
+        self.client = self._provider.session.client(
+            'route53', region_name=self._provider.region_name)
+
+        # Initialize provider services
+        self._zone_svc = AWSDnsZoneService(self.provider)
+        self._record_svc = AWSDnsRecordService(self.provider)
+
+    @property
+    def host_zones(self):
+        return self._zone_svc
+
+    @property
+    def _records(self):
+        return self._record_svc
+
+
+class AWSDnsZoneService(BaseDnsZoneService):
+
+    def __init__(self, provider):
+        super(AWSDnsZoneService, self).__init__(provider)
+
+    @dispatch(event="provider.dns.host_zones.get",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def get(self, dns_zone_id):
+        try:
+            dns_zone = self.provider.dns.client.get_hosted_zone(
+                Id=AWSDnsZone.unescape_zone_id(dns_zone_id))
+            return AWSDnsZone(self.provider, dns_zone.get('HostedZone'))
+        except self.provider.dns.client.exceptions.NoSuchHostedZone:
+            return None
+        except ClientError as exc:
+            error_code = exc.response['Error']['Code']
+            if any(status in error_code for status in
+                   ('NotFound', 'InvalidParameterValue', 'Malformed', '404')):
+                log.debug("Object not found: %s", dns_zone_id)
+                return None
+            else:
+                raise exc
+
+    @dispatch(event="provider.dns.host_zones.list",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def list(self, limit=None, marker=None):
+        response = self.provider.dns.client.list_hosted_zones(
+            **trim_empty_params({'MaxItems': limit, 'Marker': marker}))
+        cb_objs = [AWSDnsZone(self.provider, zone)
+                   for zone in response.get('HostedZones')]
+        return ServerPagedResultList(is_truncated=response.get('IsTruncated'),
+                                     marker=response.get('NextMarker'),
+                                     supports_total=False,
+                                     data=cb_objs)
+
+    @dispatch(event="provider.dns.host_zones.find",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def find(self, **kwargs):
+        filters = ['name']
+        matches = cb_helpers.generic_find(filters, kwargs, self)
+        return ClientPagedResultList(self.provider, list(matches),
+                                     limit=None, marker=None)
+
+    @dispatch(event="provider.dns.host_zones.create",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def create(self, name, admin_email):
+        AWSDnsZone.assert_valid_resource_name(name)
+
+        response = self.provider.dns.client.create_hosted_zone(
+            Name=name, CallerReference=uuid.uuid4().hex,
+            HostedZoneConfig={
+                'Comment': 'admin_email=' + admin_email
+            }
+        )
+        return AWSDnsZone(self.provider, response.get('HostedZone'))
+
+    @dispatch(event="provider.dns.host_zones.delete",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def delete(self, dns_zone):
+        dns_zone = (dns_zone if isinstance(dns_zone, AWSDnsZone)
+                    else self.get(dns_zone))
+        if dns_zone:
+            self.provider.dns.client.delete_hosted_zone(Id=dns_zone.aws_id)
+
+
+class AWSDnsRecordService(BaseDnsRecordService):
+
+    def __init__(self, provider):
+        super(AWSDnsRecordService, self).__init__(provider)
+
+    def get(self, dns_zone, rec_id):
+        try:
+            if rec_id and ":" in rec_id:
+                rec_name, rec_type = rec_id.split(":")
+                response = self.provider.dns.client.list_resource_record_sets(
+                    HostedZoneId=dns_zone.aws_id,
+                    StartRecordName=rec_name,
+                    StartRecordType=rec_type)
+                return AWSDnsRecord(self.provider, dns_zone,
+                                    response.get('ResourceRecordSets')[0])
+            else:
+                return None
+        except ClientError as exc:
+            error_code = exc.response['Error']['Code']
+            if any(status in error_code for status in
+                   ('NotFound', 'InvalidParameterValue', 'Malformed', '404')):
+                log.debug("Object not found: %s", rec_id)
+                return None
+            else:
+                raise exc
+
+    def list(self, dns_zone, limit=None, marker=None):
+        response = self.provider.dns.client.list_resource_record_sets(
+            **trim_empty_params({
+                'HostedZoneId': dns_zone.aws_id,
+                'MaxItems': limit,
+                'StartRecordIdentifier': marker
+            })
+        )
+        cb_objs = [AWSDnsRecord(self.provider, dns_zone, rec)
+                   for rec in response.get('ResourceRecordSets')]
+        return ServerPagedResultList(
+            is_truncated=response.get('IsTruncated'),
+            marker=response.get('NextRecordIdentifier'),
+            supports_total=False, data=cb_objs)
+
+    def find(self, dns_zone, **kwargs):
+        filters = ['name']
+        matches = cb_helpers.generic_find(filters, kwargs, dns_zone.records)
+        return ClientPagedResultList(self.provider, list(matches),
+                                     limit=None, marker=None)
+
+    def _to_resource_records(self, data, rec_type):
+        if isinstance(data, list):
+            records = data
+        else:
+            records = [data]
+        return [{'Value': self._standardize_record(r, rec_type)}
+                for r in records]
+
+    def create(self, dns_zone, name, type, data, ttl=None):
+        AWSDnsRecord.assert_valid_resource_name(name)
+
+        response = self.provider.dns.client.change_resource_record_sets(
+            HostedZoneId=dns_zone.aws_id,
+            ChangeBatch={
+                'Changes': [{
+                    'Action': 'CREATE',
+                    'ResourceRecordSet': trim_empty_params({
+                        'Name': name,
+                        'Type': type,
+                        'TTL': ttl or 300,
+                        'ResourceRecords': self._to_resource_records(
+                            data, type)
+                    })
+                }]
+            }
+        )
+        # FIXME: Since Moto's implementation of route53 doesn't support
+        # waiting, this is skipped for mock tests.
+        if not self.provider.PROVIDER_ID == 'mock':
+            waiter = self.provider.dns.client.get_waiter(
+                'resource_record_sets_changed')
+            waiter.wait(Id=response.get('ChangeInfo').get('Id'))
+        return self.get(dns_zone, name + ":" + type)
+
+    def delete(self, dns_zone, record):
+        rec_id = record.id if isinstance(record, AWSDnsRecord) else record
+
+        rec_name, rec_type = rec_id.split(":")
+        response = self.provider.dns.client.change_resource_record_sets(
+            HostedZoneId=dns_zone.aws_id,
+            ChangeBatch={
+                'Changes': [{
+                    'Action': 'DELETE',
+                    'ResourceRecordSet': {
+                        'Name': rec_name,
+                        'Type': rec_type,
+                        'TTL': record.ttl,
+                        'ResourceRecords': self._to_resource_records(
+                            record.data, rec_type)
+                    }
+                }]
+            })
+        # FIXME: Since Moto's implementation of route53 doesn't support
+        # waiting, this is skipped for mock tests.
+        if not self.provider.PROVIDER_ID == 'mock':
+            waiter = self.provider.dns.client.get_waiter(
+                'resource_record_sets_changed')
+            waiter.wait(Id=response.get('ChangeInfo').get('Id'))

+ 7 - 0
cloudbridge/providers/aws/subservices.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 
 
 from cloudbridge.base.subservices import BaseBucketObjectSubService
 from cloudbridge.base.subservices import BaseBucketObjectSubService
+from cloudbridge.base.subservices import BaseDnsRecordSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseSubnetSubService
 from cloudbridge.base.subservices import BaseSubnetSubService
@@ -37,3 +38,9 @@ class AWSSubnetSubService(BaseSubnetSubService):
 
 
     def __init__(self, provider, network):
     def __init__(self, provider, network):
         super(AWSSubnetSubService, self).__init__(provider, network)
         super(AWSSubnetSubService, self).__init__(provider, network)
+
+
+class AWSDnsRecordSubService(BaseDnsRecordSubService):
+
+    def __init__(self, provider, dns_zone):
+        super(AWSDnsRecordSubService, self).__init__(provider, dns_zone)

+ 28 - 0
cloudbridge/providers/gcp/provider.py

@@ -19,6 +19,7 @@ from cloudbridge.base import BaseCloudProvider
 from cloudbridge.interfaces.exceptions import ProviderConnectionException
 from cloudbridge.interfaces.exceptions import ProviderConnectionException
 
 
 from .services import GCPComputeService
 from .services import GCPComputeService
+from .services import GCPDnsService
 from .services import GCPNetworkingService
 from .services import GCPNetworkingService
 from .services import GCPSecurityService
 from .services import GCPSecurityService
 from .services import GCPStorageService
 from .services import GCPStorageService
@@ -236,14 +237,17 @@ class GCPCloudProvider(BaseCloudProvider):
         # service connections, lazily initialized
         # service connections, lazily initialized
         self._gcp_compute = None
         self._gcp_compute = None
         self._gcp_storage = None
         self._gcp_storage = None
+        self._gcp_dns = None
         self._compute_resources_cache = None
         self._compute_resources_cache = None
         self._storage_resources_cache = None
         self._storage_resources_cache = None
+        self._dns_resources_cache = None
 
 
         # Initialize provider services
         # Initialize provider services
         self._compute = GCPComputeService(self)
         self._compute = GCPComputeService(self)
         self._security = GCPSecurityService(self)
         self._security = GCPSecurityService(self)
         self._networking = GCPNetworkingService(self)
         self._networking = GCPNetworkingService(self)
         self._storage = GCPStorageService(self)
         self._storage = GCPStorageService(self)
+        self._dns = GCPDnsService(self)
 
 
     # Override base class implementation because it will cause
     # Override base class implementation because it will cause
     # an infinite loop
     # an infinite loop
@@ -267,6 +271,10 @@ class GCPCloudProvider(BaseCloudProvider):
     def storage(self):
     def storage(self):
         return self._storage
         return self._storage
 
 
+    @property
+    def dns(self):
+        return self._dns
+
     @property
     @property
     def gcp_compute(self):
     def gcp_compute(self):
         if not self._gcp_compute:
         if not self._gcp_compute:
@@ -279,6 +287,12 @@ class GCPCloudProvider(BaseCloudProvider):
             self._gcp_storage = self._connect_gcp_storage()
             self._gcp_storage = self._connect_gcp_storage()
         return self._gcp_storage
         return self._gcp_storage
 
 
+    @property
+    def gcp_dns(self):
+        if not self._gcp_dns:
+            self._gcp_dns = self._connect_gcp_dns()
+        return self._gcp_dns
+
     @property
     @property
     def _compute_resources(self):
     def _compute_resources(self):
         if not self._compute_resources_cache:
         if not self._compute_resources_cache:
@@ -295,6 +309,14 @@ class GCPCloudProvider(BaseCloudProvider):
             self._storage_resources_cache = GCPResources(self.gcp_storage)
             self._storage_resources_cache = GCPResources(self.gcp_storage)
         return self._storage_resources_cache
         return self._storage_resources_cache
 
 
+    @property
+    def _dns_resources(self):
+        if not self._dns_resources_cache:
+            self._dns_resources_cache = GCPResources(
+                self.gcp_dns,
+                project=self.project_name)
+        return self._dns_resources_cache
+
     @property
     @property
     def _credentials(self):
     def _credentials(self):
         if not self.credentials_obj:
         if not self.credentials_obj:
@@ -322,6 +344,10 @@ class GCPCloudProvider(BaseCloudProvider):
         return discovery.build('compute', 'v1', credentials=self._credentials,
         return discovery.build('compute', 'v1', credentials=self._credentials,
                                cache_discovery=False)
                                cache_discovery=False)
 
 
+    def _connect_gcp_dns(self):
+        return discovery.build('dns', 'v1', credentials=self._credentials,
+                               cache_discovery=False)
+
     def wait_for_operation(self, operation, region=None, zone=None):
     def wait_for_operation(self, operation, region=None, zone=None):
         args = {'project': self.project_name, 'operation': operation['name']}
         args = {'project': self.project_name, 'operation': operation['name']}
         if not region and not zone:
         if not region and not zone:
@@ -353,6 +379,8 @@ class GCPCloudProvider(BaseCloudProvider):
             self._compute_resources.get_resource_url_with_default(
             self._compute_resources.get_resource_url_with_default(
                 resource, url_or_name, **kwargs) or
                 resource, url_or_name, **kwargs) or
             self._storage_resources.get_resource_url_with_default(
             self._storage_resources.get_resource_url_with_default(
+                resource, url_or_name, **kwargs) or
+            self._dns_resources.get_resource_url_with_default(
                 resource, url_or_name, **kwargs))
                 resource, url_or_name, **kwargs))
         if resource_url is None:
         if resource_url is None:
             return None
             return None

+ 71 - 0
cloudbridge/providers/gcp/resources.py

@@ -18,6 +18,8 @@ import googleapiclient
 from cloudbridge.base.resources import BaseAttachmentInfo
 from cloudbridge.base.resources import BaseAttachmentInfo
 from cloudbridge.base.resources import BaseBucket
 from cloudbridge.base.resources import BaseBucket
 from cloudbridge.base.resources import BaseBucketObject
 from cloudbridge.base.resources import BaseBucketObject
+from cloudbridge.base.resources import BaseDnsRecord
+from cloudbridge.base.resources import BaseDnsZone
 from cloudbridge.base.resources import BaseFloatingIP
 from cloudbridge.base.resources import BaseFloatingIP
 from cloudbridge.base.resources import BaseInstance
 from cloudbridge.base.resources import BaseInstance
 from cloudbridge.base.resources import BaseInternetGateway
 from cloudbridge.base.resources import BaseInternetGateway
@@ -46,6 +48,7 @@ from cloudbridge.interfaces.resources import VolumeState
 
 
 from . import helpers
 from . import helpers
 from .subservices import GCPBucketObjectSubService
 from .subservices import GCPBucketObjectSubService
+from .subservices import GCPDnsRecordSubService
 from .subservices import GCPFloatingIPSubService
 from .subservices import GCPFloatingIPSubService
 from .subservices import GCPGatewaySubService
 from .subservices import GCPGatewaySubService
 from .subservices import GCPSubnetSubService
 from .subservices import GCPSubnetSubService
@@ -2024,3 +2027,71 @@ class GCPLaunchConfig(BaseLaunchConfig):
 
 
     def __init__(self, provider):
     def __init__(self, provider):
         super(GCPLaunchConfig, self).__init__(provider)
         super(GCPLaunchConfig, self).__init__(provider)
+
+
+class GCPDnsZone(BaseDnsZone):
+
+    def __init__(self, provider, dns_zone):
+        super(GCPDnsZone, self).__init__(provider)
+        self._dns_zone = dns_zone
+        self._dns_record_container = GCPDnsRecordSubService(provider, self)
+
+    @property
+    def id(self):
+        return self._dns_zone.get('name')
+
+    @property
+    def name(self):
+        return self._dns_zone.get('dnsName')
+
+    @property
+    def admin_email(self):
+        comment = self._dns_zone.get('description')
+        if comment:
+            email_field = comment.split(",")[0].split("=")
+            if email_field[0] == "admin_email":
+                return email_field[1]
+            else:
+                return None
+        else:
+            return None
+
+    @property
+    def records(self):
+        return self._dns_record_container
+
+
+class GCPDnsRecord(BaseDnsRecord):
+
+    def __init__(self, provider, dns_zone, dns_record):
+        super(GCPDnsRecord, self).__init__(provider)
+        self._dns_zone = dns_zone
+        self._dns_rec = dns_record
+
+    @property
+    def id(self):
+        return self._dns_rec.get('name') + ":" + self._dns_rec.get('type')
+
+    @property
+    def name(self):
+        return self._dns_rec.get('name')
+
+    @property
+    def zone_id(self):
+        return self._dns_zone.id
+
+    @property
+    def type(self):
+        return self._dns_rec.get('type')
+
+    @property
+    def data(self):
+        return self._dns_rec.get('rrdatas')
+
+    @property
+    def ttl(self):
+        return self._dns_rec.get('ttl')
+
+    def delete(self):
+        # pylint:disable=protected-access
+        return self._provider.dns._records.delete(self._dns_zone, self)

+ 217 - 0
cloudbridge/providers/gcp/services.py

@@ -14,6 +14,9 @@ from cloudbridge.base.resources import ServerPagedResultList
 from cloudbridge.base.services import BaseBucketObjectService
 from cloudbridge.base.services import BaseBucketObjectService
 from cloudbridge.base.services import BaseBucketService
 from cloudbridge.base.services import BaseBucketService
 from cloudbridge.base.services import BaseComputeService
 from cloudbridge.base.services import BaseComputeService
+from cloudbridge.base.services import BaseDnsRecordService
+from cloudbridge.base.services import BaseDnsService
+from cloudbridge.base.services import BaseDnsZoneService
 from cloudbridge.base.services import BaseFloatingIPService
 from cloudbridge.base.services import BaseFloatingIPService
 from cloudbridge.base.services import BaseGatewayService
 from cloudbridge.base.services import BaseGatewayService
 from cloudbridge.base.services import BaseImageService
 from cloudbridge.base.services import BaseImageService
@@ -39,6 +42,8 @@ from cloudbridge.providers.gcp import helpers
 
 
 from .resources import GCPBucket
 from .resources import GCPBucket
 from .resources import GCPBucketObject
 from .resources import GCPBucketObject
+from .resources import GCPDnsRecord
+from .resources import GCPDnsZone
 from .resources import GCPFirewallsDelegate
 from .resources import GCPFirewallsDelegate
 from .resources import GCPFloatingIP
 from .resources import GCPFloatingIP
 from .resources import GCPInstance
 from .resources import GCPInstance
@@ -1601,3 +1606,215 @@ class GCPFloatingIPService(BaseFloatingIPService):
                     .execute())
                     .execute())
         self.provider.wait_for_operation(response,
         self.provider.wait_for_operation(response,
                                          region=fip.region_name)
                                          region=fip.region_name)
+
+
+class GCPDnsService(BaseDnsService):
+
+    def __init__(self, provider):
+        super(GCPDnsService, self).__init__(provider)
+
+        # Initialize provider services
+        self._zone_svc = GCPDnsZoneService(self.provider)
+        self._record_svc = GCPDnsRecordService(self.provider)
+
+    @property
+    def host_zones(self):
+        return self._zone_svc
+
+    @property
+    def _records(self):
+        return self._record_svc
+
+
+class GCPDnsZoneService(BaseDnsZoneService):
+
+    def __init__(self, provider):
+        super(GCPDnsZoneService, self).__init__(provider)
+
+    @dispatch(event="provider.dns.host_zones.get",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def get(self, dns_zone_id):
+        dns_zone = self.provider.get_resource(
+            'managedZones', dns_zone_id, project=self._provider.project_name)
+        return GCPDnsZone(self.provider, dns_zone) if dns_zone else None
+
+    @dispatch(event="provider.dns.host_zones.list",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def list(self, limit=None, marker=None):
+        max_result = limit if limit is not None and limit < 500 else 500
+        response = (self.provider
+                        .gcp_dns
+                        .managedZones()
+                        .list(project=self.provider.project_name,
+                              maxResults=max_result,
+                              pageToken=marker)
+                        .execute())
+        dns_zones = []
+        for dns_zone in response.get('managedZones', []):
+            dns_zones.append(GCPDnsZone(self.provider, dns_zone))
+        if len(dns_zones) > max_result:
+            log.warning('Expected at most %d results; got %d',
+                        max_result, len(dns_zones))
+        return ServerPagedResultList('nextPageToken' in response,
+                                     response.get('nextPageToken'),
+                                     False, data=dns_zones)
+
+    @dispatch(event="provider.dns.host_zones.find",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def find(self, **kwargs):
+        filters = ['name']
+        matches = cb_helpers.generic_find(filters, kwargs, self)
+        return ClientPagedResultList(self.provider, list(matches),
+                                     limit=None, marker=None)
+
+    @dispatch(event="provider.dns.host_zones.create",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def create(self, name, admin_email):
+        GCPDnsZone.assert_valid_resource_name(name)
+        body = {
+            'kind': 'dns#managedZone',
+            'name': cb_helpers.to_resource_name(name),
+            'dnsName':  self._get_fully_qualified_dns(name),
+            'description': 'admin_email=' + admin_email,
+            'visibility': 'public'
+        }
+        try:
+            response = (self.provider
+                            .gcp_dns
+                            .managedZones()
+                            .create(project=self.provider.project_name,
+                                    body=body)
+                            .execute())
+            return GCPDnsZone(self.provider, response)
+        except googleapiclient.errors.HttpError as http_error:
+            # 409 = conflict
+            if http_error.resp.status in [409]:
+                raise DuplicateResourceException(
+                    'DNS Zone already exists with name {0}'.format(name))
+            else:
+                raise
+
+    @dispatch(event="provider.dns.host_zones.delete",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def delete(self, dns_zone):
+        zone = (dns_zone if isinstance(dns_zone, GCPDnsZone)
+                else self.get(dns_zone))
+        if zone:
+            (self.provider
+                 .gcp_dns
+                 .managedZones()
+                 .delete(project=self.provider.project_name,
+                         managedZone=zone.id)
+                 .execute())
+
+
+class GCPDnsRecordService(BaseDnsRecordService):
+
+    def __init__(self, provider):
+        super(GCPDnsRecordService, self).__init__(provider)
+
+    def _to_resource_records(self, data, rec_type):
+        """
+        Converts a record to what GCP expects. For example, GCP
+        expects a fully qualified name for all CNAME records.
+        """
+        if isinstance(data, list):
+            records = data
+        else:
+            records = [data]
+        return [self._standardize_record(r, rec_type) for r in records]
+
+    def get(self, dns_zone, rec_id):
+        if rec_id and ":" in rec_id:
+            rec_name, rec_type = rec_id.split(":")
+            response = (self.provider
+                        .gcp_dns
+                        .resourceRecordSets()
+                        .list(project=self.provider.project_name,
+                              managedZone=dns_zone.id,
+                              name=self._get_fully_qualified_dns(rec_name),
+                              type=rec_type)
+                        .execute())
+            if len(response.get('rrsets', [])) > 1:
+                log.warning('Expected at most %d results; got %d',
+                            1, len(response.get('items', [])))
+            for rec in response.get('rrsets', []):
+                return GCPDnsRecord(self.provider, dns_zone, rec)
+            return None
+        else:
+            return None
+
+    def list(self, dns_zone, limit=None, marker=None, rec_id=None):
+        max_result = limit if limit is not None and limit < 500 else 500
+        response = (self.provider
+                        .gcp_dns
+                        .resourceRecordSets()
+                        .list(project=self.provider.project_name,
+                              managedZone=dns_zone.id,
+                              maxResults=max_result,
+                              pageToken=marker)
+                        .execute())
+        records = []
+        for rec in response.get('rrsets', []):
+            records.append(GCPDnsRecord(self.provider, dns_zone, rec))
+        if len(records) > max_result:
+            log.warning('Expected at most %d results; got %d',
+                        max_result, len(records))
+        return ServerPagedResultList('nextPageToken' in response,
+                                     response.get('nextPageToken'),
+                                     False, data=records)
+
+    def find(self, dns_zone, **kwargs):
+        filters = ['name']
+        matches = cb_helpers.generic_find(filters, kwargs, dns_zone.records)
+        return ClientPagedResultList(self.provider, list(matches),
+                                     limit=None, marker=None)
+
+    def create(self, dns_zone, name, type, data, ttl=None):
+        GCPDnsZone.assert_valid_resource_name(name)
+        body = {
+            'kind': 'dns#change',
+            "additions": [
+                {
+                    'kind': 'dns#resourceRecordSet',
+                    'name': self._get_fully_qualified_dns(name),
+                    'type': type,
+                    'ttl': ttl,
+                    'rrdatas': self._to_resource_records(data, type)
+                }
+            ]
+        }
+        (self.provider
+             .gcp_dns
+             .changes()
+             .create(project=self.provider.project_name,
+                     managedZone=dns_zone.id,
+                     body=body)
+             .execute())
+        rec_id = name + ":" + type
+        return self.get(dns_zone, rec_id)
+
+    def delete(self, dns_zone, record):
+        rec = record if isinstance(record, GCPDnsRecord) else self.get(record)
+
+        if rec:
+            body = {
+                'kind': 'dns#change',
+                "deletions": [
+                    {
+                        'kind': 'dns#resourceRecordSet',
+                        'name': self._get_fully_qualified_dns(rec.name),
+                        'type': rec.type,
+                        'ttl': rec.ttl,
+                        'rrdatas': self._to_resource_records(
+                            rec.data, rec.type)
+                    }
+                ]
+            }
+            (self.provider
+                 .gcp_dns
+                 .changes()
+                 .create(project=self.provider.project_name,
+                         managedZone=dns_zone.id,
+                         body=body)
+                 .execute())

+ 7 - 0
cloudbridge/providers/gcp/subservices.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 
 
 from cloudbridge.base.subservices import BaseBucketObjectSubService
 from cloudbridge.base.subservices import BaseBucketObjectSubService
+from cloudbridge.base.subservices import BaseDnsRecordSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseSubnetSubService
 from cloudbridge.base.subservices import BaseSubnetSubService
@@ -37,3 +38,9 @@ class GCPSubnetSubService(BaseSubnetSubService):
 
 
     def __init__(self, provider, network):
     def __init__(self, provider, network):
         super(GCPSubnetSubService, self).__init__(provider, network)
         super(GCPSubnetSubService, self).__init__(provider, network)
+
+
+class GCPDnsRecordSubService(BaseDnsRecordSubService):
+
+    def __init__(self, provider, dns_zone):
+        super(GCPDnsRecordSubService, self).__init__(provider, dns_zone)

+ 4 - 0
cloudbridge/providers/mock/provider.py

@@ -7,6 +7,7 @@
     See notes below.
     See notes below.
 """
 """
 from moto import mock_ec2
 from moto import mock_ec2
+from moto import mock_route53
 from moto import mock_s3
 from moto import mock_s3
 
 
 import responses
 import responses
@@ -37,6 +38,8 @@ class MockAWSCloudProvider(AWSCloudProvider, TestMockHelperMixin):
         self.ec2mock.start()
         self.ec2mock.start()
         self.s3mock = mock_s3()
         self.s3mock = mock_s3()
         self.s3mock.start()
         self.s3mock.start()
+        self.route53mock = mock_route53()
+        self.route53mock.start()
         responses.add(
         responses.add(
             responses.GET,
             responses.GET,
             self.AWS_INSTANCE_DATA_DEFAULT_URL,
             self.AWS_INSTANCE_DATA_DEFAULT_URL,
@@ -98,3 +101,4 @@ class MockAWSCloudProvider(AWSCloudProvider, TestMockHelperMixin):
         """
         """
         self.s3mock.stop()
         self.s3mock.stop()
         self.ec2mock.stop()
         self.ec2mock.stop()
+        self.route53mock.stop()

+ 6 - 0
cloudbridge/providers/openstack/provider.py

@@ -21,6 +21,7 @@ from cloudbridge.base import BaseCloudProvider
 from cloudbridge.base.helpers import get_env
 from cloudbridge.base.helpers import get_env
 
 
 from .services import OpenStackComputeService
 from .services import OpenStackComputeService
+from .services import OpenStackDnsService
 from .services import OpenStackNetworkingService
 from .services import OpenStackNetworkingService
 from .services import OpenStackSecurityService
 from .services import OpenStackSecurityService
 from .services import OpenStackStorageService
 from .services import OpenStackStorageService
@@ -72,6 +73,7 @@ class OpenStackCloudProvider(BaseCloudProvider):
         self._networking = OpenStackNetworkingService(self)
         self._networking = OpenStackNetworkingService(self)
         self._security = OpenStackSecurityService(self)
         self._security = OpenStackSecurityService(self)
         self._storage = OpenStackStorageService(self)
         self._storage = OpenStackStorageService(self)
+        self._dns = OpenStackDnsService(self)
 
 
     @property
     @property
     def nova(self):
     def nova(self):
@@ -184,6 +186,10 @@ class OpenStackCloudProvider(BaseCloudProvider):
     def storage(self):
     def storage(self):
         return self._storage
         return self._storage
 
 
+    @property
+    def dns(self):
+        return self._dns
+
     def _connect_nova(self):
     def _connect_nova(self):
         return self._connect_nova_region(self.region_name)
         return self._connect_nova_region(self.region_name)
 
 

+ 64 - 0
cloudbridge/providers/openstack/resources.py

@@ -26,6 +26,8 @@ from swiftclient.utils import generate_temp_url
 from cloudbridge.base.resources import BaseAttachmentInfo
 from cloudbridge.base.resources import BaseAttachmentInfo
 from cloudbridge.base.resources import BaseBucket
 from cloudbridge.base.resources import BaseBucket
 from cloudbridge.base.resources import BaseBucketObject
 from cloudbridge.base.resources import BaseBucketObject
+from cloudbridge.base.resources import BaseDnsRecord
+from cloudbridge.base.resources import BaseDnsZone
 from cloudbridge.base.resources import BaseFloatingIP
 from cloudbridge.base.resources import BaseFloatingIP
 from cloudbridge.base.resources import BaseInstance
 from cloudbridge.base.resources import BaseInstance
 from cloudbridge.base.resources import BaseInternetGateway
 from cloudbridge.base.resources import BaseInternetGateway
@@ -52,6 +54,7 @@ from cloudbridge.interfaces.resources import TrafficDirection
 from cloudbridge.interfaces.resources import VolumeState
 from cloudbridge.interfaces.resources import VolumeState
 
 
 from .subservices import OpenStackBucketObjectSubService
 from .subservices import OpenStackBucketObjectSubService
+from .subservices import OpenStackDnsRecordSubService
 from .subservices import OpenStackFloatingIPSubService
 from .subservices import OpenStackFloatingIPSubService
 from .subservices import OpenStackGatewaySubService
 from .subservices import OpenStackGatewaySubService
 from .subservices import OpenStackSubnetSubService
 from .subservices import OpenStackSubnetSubService
@@ -1344,3 +1347,64 @@ class OpenStackBucket(BaseBucket):
     @property
     @property
     def objects(self):
     def objects(self):
         return self._object_container
         return self._object_container
+
+
+class OpenStackDnsZone(BaseDnsZone):
+
+    def __init__(self, provider, dns_zone):
+        super(OpenStackDnsZone, self).__init__(provider)
+        self._dns_zone = dns_zone
+        self._dns_record_container = OpenStackDnsRecordSubService(
+            provider, self)
+
+    @property
+    def id(self):
+        return self._dns_zone.id
+
+    @property
+    def name(self):
+        return self._dns_zone.name
+
+    @property
+    def admin_email(self):
+        return self._dns_zone.email
+
+    @property
+    def records(self):
+        return self._dns_record_container
+
+
+class OpenStackDnsRecord(BaseDnsRecord):
+
+    def __init__(self, provider, dns_zone, dns_record):
+        super(OpenStackDnsRecord, self).__init__(provider)
+        self._dns_zone = dns_zone
+        self._dns_rec = dns_record
+
+    @property
+    def id(self):
+        return self._dns_rec.id
+
+    @property
+    def name(self):
+        return self._dns_rec.name
+
+    @property
+    def zone_id(self):
+        return self._dns_zone.id
+
+    @property
+    def type(self):
+        return self._dns_rec.type
+
+    @property
+    def data(self):
+        return self._dns_rec.records
+
+    @property
+    def ttl(self):
+        return self._dns_rec.ttl
+
+    def delete(self):
+        # pylint:disable=protected-access
+        return self._provider.dns._records.delete(self._dns_zone, self)

+ 130 - 0
cloudbridge/providers/openstack/services.py

@@ -10,6 +10,7 @@ from neutronclient.common.exceptions import PortNotFoundClient
 
 
 from novaclient.exceptions import NotFound as NovaNotFound
 from novaclient.exceptions import NotFound as NovaNotFound
 
 
+from openstack.exceptions import BadRequestException
 from openstack.exceptions import HttpException
 from openstack.exceptions import HttpException
 from openstack.exceptions import NotFoundException
 from openstack.exceptions import NotFoundException
 from openstack.exceptions import ResourceNotFound
 from openstack.exceptions import ResourceNotFound
@@ -23,6 +24,9 @@ from cloudbridge.base.resources import ClientPagedResultList
 from cloudbridge.base.services import BaseBucketObjectService
 from cloudbridge.base.services import BaseBucketObjectService
 from cloudbridge.base.services import BaseBucketService
 from cloudbridge.base.services import BaseBucketService
 from cloudbridge.base.services import BaseComputeService
 from cloudbridge.base.services import BaseComputeService
+from cloudbridge.base.services import BaseDnsRecordService
+from cloudbridge.base.services import BaseDnsService
+from cloudbridge.base.services import BaseDnsZoneService
 from cloudbridge.base.services import BaseFloatingIPService
 from cloudbridge.base.services import BaseFloatingIPService
 from cloudbridge.base.services import BaseGatewayService
 from cloudbridge.base.services import BaseGatewayService
 from cloudbridge.base.services import BaseImageService
 from cloudbridge.base.services import BaseImageService
@@ -59,6 +63,8 @@ from cloudbridge.interfaces.resources import Volume
 from . import helpers as oshelpers
 from . import helpers as oshelpers
 from .resources import OpenStackBucket
 from .resources import OpenStackBucket
 from .resources import OpenStackBucketObject
 from .resources import OpenStackBucketObject
+from .resources import OpenStackDnsRecord
+from .resources import OpenStackDnsZone
 from .resources import OpenStackFloatingIP
 from .resources import OpenStackFloatingIP
 from .resources import OpenStackInstance
 from .resources import OpenStackInstance
 from .resources import OpenStackInternetGateway
 from .resources import OpenStackInternetGateway
@@ -1312,3 +1318,127 @@ class OpenStackFloatingIPService(BaseFloatingIPService):
                 log.debug("Floating IP %s not found.", fip)
                 log.debug("Floating IP %s not found.", fip)
                 return True
                 return True
         os_ip.delete(self._provider.os_conn.session)
         os_ip.delete(self._provider.os_conn.session)
+
+
+class OpenStackDnsService(BaseDnsService):
+
+    def __init__(self, provider):
+        super(OpenStackDnsService, self).__init__(provider)
+
+        # Initialize provider services
+        self._zone_svc = OpenStackDnsZoneService(self.provider)
+        self._record_svc = OpenStackDnsRecordService(self.provider)
+
+    @property
+    def host_zones(self):
+        return self._zone_svc
+
+    @property
+    def _records(self):
+        return self._record_svc
+
+
+class OpenStackDnsZoneService(BaseDnsZoneService):
+
+    def __init__(self, provider):
+        super(OpenStackDnsZoneService, self).__init__(provider)
+
+    @dispatch(event="provider.dns.host_zones.get",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def get(self, dns_zone_id):
+        try:
+            return OpenStackDnsZone(
+                self.provider,
+                self.provider.os_conn.dns.get_zone(dns_zone_id))
+        except (ResourceNotFound, NotFoundException, BadRequestException):
+            log.debug("Dns Zone %s not found.", dns_zone_id)
+            return None
+
+    @dispatch(event="provider.dns.host_zones.list",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def list(self, limit=None, marker=None):
+        zones = [OpenStackDnsZone(self.provider, zone)
+                 for zone in self.provider.os_conn.dns.zones()]
+        return ClientPagedResultList(self.provider, zones,
+                                     limit=limit, marker=marker)
+
+    @dispatch(event="provider.dns.host_zones.find",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def find(self, **kwargs):
+        filters = ['name']
+        matches = cb_helpers.generic_find(filters, kwargs, self)
+        return ClientPagedResultList(self.provider, list(matches),
+                                     limit=None, marker=None)
+
+    @dispatch(event="provider.dns.host_zones.create",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def create(self, name, admin_email):
+        OpenStackDnsZone.assert_valid_resource_name(name)
+
+        return OpenStackDnsZone(
+            self.provider, self.provider.os_conn.dns.create_zone(
+                name=self._get_fully_qualified_dns(name),
+                email=admin_email, ttl=3600))
+
+    @dispatch(event="provider.dns.host_zones.delete",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def delete(self, dns_zone):
+        zone_id = (dns_zone.id if isinstance(dns_zone, OpenStackDnsZone)
+                   else dns_zone)
+        if zone_id:
+            self.provider.os_conn.dns.delete_zone(zone_id)
+
+
+class OpenStackDnsRecordService(BaseDnsRecordService):
+
+    def __init__(self, provider):
+        super(OpenStackDnsRecordService, self).__init__(provider)
+
+    def _to_resource_records(self, data, rec_type):
+        """
+        Converts a record to what OpenStack expects. For example,
+        OpenStack expects a fully qualified name for all CNAME records.
+        """
+        if isinstance(data, list):
+            records = data
+        else:
+            records = [data]
+        return [self._standardize_record(r, rec_type) for r in records]
+
+    def get(self, dns_zone, rec_id):
+        try:
+            return OpenStackDnsRecord(
+                self.provider, dns_zone,
+                self.provider.os_conn.dns.get_recordset(rec_id, dns_zone.id))
+        except (ResourceNotFound, NotFoundException, BadRequestException):
+            log.debug("Dns Record %s not found.", rec_id)
+            return None
+
+    def list(self, dns_zone, limit=None, marker=None):
+        recs = [OpenStackDnsRecord(self.provider, dns_zone, rec)
+                for rec in self.provider.os_conn.dns.recordsets(dns_zone.id)]
+        return ClientPagedResultList(self.provider, recs,
+                                     limit=limit, marker=marker)
+
+    def find(self, dns_zone, **kwargs):
+        filters = ['name']
+        matches = cb_helpers.generic_find(filters, kwargs, dns_zone.records)
+        return ClientPagedResultList(self.provider, list(matches),
+                                     limit=None, marker=None)
+
+    def create(self, dns_zone, name, type, data, ttl=None):
+        OpenStackDnsZone.assert_valid_resource_name(name)
+
+        return OpenStackDnsRecord(
+            self.provider, dns_zone,
+            self.provider.os_conn.dns.create_recordset(
+                zone=dns_zone.id, name=name, type=type,
+                records=self._to_resource_records(data, type),
+                ttl=ttl or 3600))
+
+    def delete(self, dns_zone, record):
+        rec_id = (record.id if isinstance(record, OpenStackDnsRecord)
+                  else record)
+        if rec_id:
+            self.provider.os_conn.dns.delete_recordset(
+                rec_id, zone=dns_zone.id)

+ 7 - 0
cloudbridge/providers/openstack/subservices.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 
 
 from cloudbridge.base.subservices import BaseBucketObjectSubService
 from cloudbridge.base.subservices import BaseBucketObjectSubService
+from cloudbridge.base.subservices import BaseDnsRecordSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseSubnetSubService
 from cloudbridge.base.subservices import BaseSubnetSubService
@@ -39,3 +40,9 @@ class OpenStackSubnetSubService(BaseSubnetSubService):
 
 
     def __init__(self, provider, network):
     def __init__(self, provider, network):
         super(OpenStackSubnetSubService, self).__init__(provider, network)
         super(OpenStackSubnetSubService, self).__init__(provider, network)
+
+
+class OpenStackDnsRecordSubService(BaseDnsRecordSubService):
+
+    def __init__(self, provider, dns_zone):
+        super(OpenStackDnsRecordSubService, self).__init__(provider, dns_zone)

+ 1 - 1
setup.py

@@ -50,7 +50,7 @@ REQS_GCP = [
     'oauth2client<=4.1.3'
     'oauth2client<=4.1.3'
 ]
 ]
 REQS_OPENSTACK = [
 REQS_OPENSTACK = [
-    'openstacksdk>=0.12.0,<=0.17',
+    'openstacksdk>=0.12.0',
     'python-novaclient>=7.0.0,<=11.0',
     'python-novaclient>=7.0.0,<=11.0',
     'python-glanceclient>=2.5.0,<=2.12',
     'python-glanceclient>=2.5.0,<=2.12',
     'python-cinderclient>=1.9.0,<=4.0',
     'python-cinderclient>=1.9.0,<=4.0',

+ 5 - 8
tests/helpers/standard_interface_tests.py

@@ -7,8 +7,6 @@ This includes:
 """
 """
 import uuid
 import uuid
 
 
-from six.moves.urllib.parse import quote_plus
-
 import tenacity
 import tenacity
 
 
 from cloudbridge.base import helpers as cb_helpers
 from cloudbridge.base import helpers as cb_helpers
@@ -122,7 +120,7 @@ def check_get(test, service, obj):
 
 
 def check_get_non_existent(test, service):
 def check_get_non_existent(test, service):
     # check get
     # check get
-    get_objs = service.get('tmp-' + str(uuid.uuid4()))
+    get_objs = service.get('tmp-' + str(uuid.uuid4())[:28])
     test.assertIsNone(
     test.assertIsNone(
         get_objs,
         get_objs,
         "Get non-existent object for %s returned unexpected objects: %s"
         "Get non-existent object for %s returned unexpected objects: %s"
@@ -149,11 +147,10 @@ def check_obj_id(test, obj):
     id_property = getattr(type(obj), 'id', None)
     id_property = getattr(type(obj), 'id', None)
     test.assertIsInstance(id_property, property)
     test.assertIsInstance(id_property, property)
     test.assertIsNone(id_property.fset, "Id should not have a setter")
     test.assertIsNone(id_property.fset, "Id should not have a setter")
-    # Non-url safe characters trip up djcloudbridge or anything that needs to
-    # use the ID in a url so make sure ids do not contain them
-    test.assertEqual(quote_plus(obj.id), obj.id,
-                     "IDs should only contain URL friendly chars that do not "
-                     "require encoding but contains: %s" % (obj.id,))
+    # Some delimiter characters can trip up djcloudbridge url reversing
+    # so make sure ids do not contain them
+    test.assertTrue("/" not in obj.id,
+                    "IDs should not contain slash but is: %s" % (obj.id,))
 
 
 
 
 def check_obj_name(test, obj):
 def check_obj_name(test, obj):

+ 117 - 0
tests/test_dns_service.py

@@ -0,0 +1,117 @@
+from cloudbridge.base import helpers as cb_helpers
+from cloudbridge.interfaces.resources import DnsRecord
+from cloudbridge.interfaces.resources import DnsRecordType
+from cloudbridge.interfaces.resources import DnsZone
+
+from tests import helpers
+from tests.helpers import ProviderTestBase
+from tests.helpers import standard_interface_tests as sit
+
+
+class CloudDnsServiceTestCase(ProviderTestBase):
+
+    _multiprocess_can_split_ = True
+
+    @helpers.skipIfNoService(['dns.host_zones'])
+    def test_crud_dns_zones(self):
+
+        def create_dns_zone(name):
+            if name:
+                name = name + ".com."
+            return self.provider.dns.host_zones.create(
+                name, "admin@cloudve.org")
+
+        def cleanup_dns_zone(dns_zone):
+            if dns_zone:
+                dns_zone.delete()
+
+        def test_zone_props(dns_zone):
+            self.assertEqual(dns_zone.admin_email, "admin@cloudve.org")
+
+        sit.check_crud(self, self.provider.dns.host_zones, DnsZone,
+                       "cb-crudzone", create_dns_zone, cleanup_dns_zone,
+                       skip_name_check=True, extra_test_func=test_zone_props)
+
+    @helpers.skipIfNoService(['dns.host_zones'])
+    def test_create_dns_zones_not_fully_qualified(self):
+        zone_name = "cb-dnszonenfq-{0}.com".format(helpers.get_uuid())
+        test_zone = None
+        with cb_helpers.cleanup_action(lambda: test_zone.delete()):
+            # If zone name is not fully qualified, it should automatically be
+            # handled
+            test_zone = self.provider.dns.host_zones.create(
+                zone_name, "admin@cloudve.org")
+
+    @helpers.skipIfNoService(['dns.host_zones'])
+    def test_crud_dns_record(self):
+        test_zone = None
+        zone_name = "cb-dnsrec-{0}.com.".format(helpers.get_uuid())
+
+        def create_dns_rec(name):
+            if name:
+                name = name + "." + zone_name
+            else:
+                name = zone_name
+            return test_zone.records.create(
+                name, DnsRecordType.A, data='10.1.1.1')
+
+        def cleanup_dns_rec(dns_rec):
+            if dns_rec:
+                dns_rec.delete()
+
+        with cb_helpers.cleanup_action(lambda: test_zone.delete()):
+            test_zone = self.provider.dns.host_zones.create(
+                zone_name, "admin@cloudve.org")
+            sit.check_crud(self, test_zone.records, DnsRecord,
+                           "cb-dnsrec", create_dns_rec,
+                           cleanup_dns_rec, skip_name_check=True)
+
+    @helpers.skipIfNoService(['dns.host_zones'])
+    def test_dns_record_properties(self):
+        test_zone = None
+        zone_name = "cb-recprop-{0}.com.".format(helpers.get_uuid())
+
+        with cb_helpers.cleanup_action(lambda: test_zone.delete()):
+            test_zone = self.provider.dns.host_zones.create(
+                zone_name, "admin@cloudve.org")
+            test_rec = None
+
+            with cb_helpers.cleanup_action(lambda: test_rec.delete()):
+                zone_name = "subdomain." + zone_name
+                test_rec = test_zone.records.create(
+                    zone_name, DnsRecordType.CNAME, data='hello.com.', ttl=500)
+                self.assertEqual(test_rec.zone_id, test_zone.id)
+                self.assertEqual(test_rec.type, DnsRecordType.CNAME)
+                self.assertEqual(test_rec.data, ['hello.com.'])
+                self.assertEqual(test_rec.ttl, 500)
+
+            # Check setting data array
+            test_rec2 = None
+            with cb_helpers.cleanup_action(lambda: test_rec2.delete()):
+                MX_DATA = ['10 mx1.hello.com.', '20 mx2.hello.com.']
+                test_rec2 = test_zone.records.create(
+                    zone_name, DnsRecordType.MX, data=MX_DATA, ttl=300)
+                self.assertEqual(test_rec2.zone_id, test_zone.id)
+                self.assertEqual(test_rec2.type, DnsRecordType.MX)
+                self.assertSetEqual(set(test_rec2.data), set(MX_DATA))
+                self.assertEqual(test_rec2.ttl, 300)
+
+    @helpers.skipIfNoService(['dns.host_zones'])
+    def test_create_dns_rec_not_fully_qualified(self):
+        test_zone = None
+        root_zone_name = "cb-recprop-{0}.com.".format(helpers.get_uuid())
+
+        with cb_helpers.cleanup_action(lambda: test_zone.delete()):
+            test_zone = self.provider.dns.host_zones.create(
+                root_zone_name, "admin@cloudve.org")
+            test_rec = None
+
+            with cb_helpers.cleanup_action(lambda: test_rec.delete()):
+                zone_name = "subdomain." + root_zone_name
+                test_rec = test_zone.records.create(
+                    zone_name, DnsRecordType.CNAME, data='hello.com', ttl=500)
+
+            with cb_helpers.cleanup_action(lambda: test_rec.delete()):
+                test_rec = test_zone.records.create(
+                    root_zone_name, DnsRecordType.MX,
+                    data=['10 mx1.hello.com', '20 mx2.hello.com'], ttl=500)