Procházet zdrojové kódy

Merge pull request #211 from nuwang/dnssupport

[WIP] Dns support for cloudbridge
Nuwan Goonasekera před 6 roky
rodič
revize
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.asymmetric import rsa
 
-from deprecation import deprecated
+from deprecated import deprecated
 
 import six
 
@@ -165,3 +165,19 @@ def rename_kwargs(func_name, kwargs, aliases):
                        details='{} is deprecated, use {} instead'.format(
                            alias, new))(lambda: None)()
             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 BucketObject
 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 FloatingIpState
 from cloudbridge.interfaces.resources import GatewayState
@@ -872,7 +874,6 @@ class BaseInternetGateway(BaseCloudResource, BaseObjectLifeCycleMixin,
 
     def __init__(self, provider):
         super(BaseInternetGateway, self).__init__(provider)
-        self.__provider = provider
 
     def __eq__(self, other):
         return (isinstance(other, InternetGateway) and
@@ -890,3 +891,74 @@ class BaseInternetGateway(BaseCloudResource, BaseObjectLifeCycleMixin,
     def delete(self):
         return self._provider.networking._gateways.delete(self.network_id,
                                                           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
 
 from cloudbridge.interfaces.exceptions import InvalidParamException
+from cloudbridge.interfaces.resources import DnsRecordType
 from cloudbridge.interfaces.resources import Network
 from cloudbridge.interfaces.services import BucketObjectService
 from cloudbridge.interfaces.services import BucketService
 from cloudbridge.interfaces.services import CloudService
 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 GatewayService
 from cloudbridge.interfaces.services import ImageService
@@ -343,22 +347,12 @@ class BaseGatewayService(GatewayService, BaseCloudService):
 
     def __init__(self, provider):
         super(BaseGatewayService, self).__init__(provider)
-        self._provider = provider
-
-    @property
-    def provider(self):
-        return self._provider
 
 
 class BaseFloatingIPService(FloatingIPService, BaseCloudService):
 
     def __init__(self, provider):
         super(BaseFloatingIPService, self).__init__(provider)
-        self._provider = provider
-
-    @property
-    def provider(self):
-        return self._provider
 
     @dispatch(event="provider.networking.floating_ips.find",
               priority=BaseCloudService.STANDARD_EVENT_PRIORITY)
@@ -367,3 +361,35 @@ class BaseFloatingIPService(FloatingIPService, BaseCloudService):
         filters = ['name', 'public_ip']
         matches = cb_helpers.generic_find(filters, kwargs, obj_list)
         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
 
 from cloudbridge.interfaces.subservices import BucketObjectSubService
+from cloudbridge.interfaces.subservices import DnsRecordSubService
 from cloudbridge.interfaces.subservices import FloatingIPSubService
 from cloudbridge.interfaces.subservices import GatewaySubService
 from cloudbridge.interfaces.subservices import SubnetSubService
@@ -166,3 +167,36 @@ class BaseSubnetSubService(SubnetSubService, BasePageableObjectMixin):
 
     def delete(self, 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):
     """
     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
     TrafficDirection.OUTBOUND.
     """

+ 19 - 0
cloudbridge/interfaces/provider.py

@@ -232,6 +232,25 @@ class CloudProvider(object):
         """
         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):
     """

+ 106 - 0
cloudbridge/interfaces/resources.py

@@ -24,6 +24,7 @@ class CloudServiceType(object):
     SECURITY = 'security'
     VOLUME = 'storage.volumes'
     BUCKET = 'storage.buckets'
+    DNS = 'dns'
 
 
 class CloudResource(object):
@@ -1306,6 +1307,111 @@ class InternetGateway(ObjectLifeCycleMixin, Gateway):
     __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):
     """
     Contains attachment information for a volume.

+ 156 - 0
cloudbridge/interfaces/services.py

@@ -873,6 +873,162 @@ class RouterService(PageableObjectMixin, CloudService):
         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):
 
     """

+ 83 - 1
cloudbridge/interfaces/subservices.py

@@ -222,7 +222,7 @@ class VMFirewallRuleSubService(PageableObjectMixin):
         pass
 
     @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):
         """
         Create a VM firewall rule.
@@ -395,3 +395,85 @@ class SubnetSubService(PageableObjectMixin):
         :param subnet_id: The ID of the Subnet to be deleted.
         """
         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 .services import AWSComputeService
+from .services import AWSDnsService
 from .services import AWSNetworkingService
 from .services import AWSSecurityService
 from .services import AWSStorageService
@@ -55,6 +56,7 @@ class AWSCloudProvider(BaseCloudProvider):
         self._networking = AWSNetworkingService(self)
         self._security = AWSSecurityService(self)
         self._storage = AWSStorageService(self)
+        self._dns = AWSDnsService(self)
 
     @property
     def session(self):
@@ -94,6 +96,10 @@ class AWSCloudProvider(BaseCloudProvider):
     def storage(self):
         return self._storage
 
+    @property
+    def dns(self):
+        return self._dns
+
     def _connect_ec2(self):
         """
         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 BaseBucket
 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 BaseInstance
 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 trim_empty_params
 from .subservices import AWSBucketObjectSubService
+from .subservices import AWSDnsRecordSubService
 from .subservices import AWSFloatingIPSubService
 from .subservices import AWSGatewaySubService
 from .subservices import AWSSubnetSubService
@@ -1138,3 +1141,85 @@ class AWSLaunchConfig(BaseLaunchConfig):
 
     def __init__(self, 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 logging
 import string
+import uuid
 
 from botocore.exceptions import ClientError
 
@@ -12,9 +13,13 @@ import requests
 import cloudbridge.base.helpers as cb_helpers
 from cloudbridge.base.middleware import dispatch
 from cloudbridge.base.resources import ClientPagedResultList
+from cloudbridge.base.resources import ServerPagedResultList
 from cloudbridge.base.services import BaseBucketObjectService
 from cloudbridge.base.services import BaseBucketService
 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 BaseGatewayService
 from cloudbridge.base.services import BaseImageService
@@ -51,6 +56,8 @@ from .helpers import BotoS3Service
 from .helpers import trim_empty_params
 from .resources import AWSBucket
 from .resources import AWSBucketObject
+from .resources import AWSDnsRecord
+from .resources import AWSDnsZone
 from .resources import AWSFloatingIP
 from .resources import AWSInstance
 from .resources import AWSInternetGateway
@@ -1300,3 +1307,195 @@ class AWSFloatingIPService(BaseFloatingIPService):
         else:
             aws_fip = self.svc.get_raw(fip)
         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
 
 from cloudbridge.base.subservices import BaseBucketObjectSubService
+from cloudbridge.base.subservices import BaseDnsRecordSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseSubnetSubService
@@ -37,3 +38,9 @@ class AWSSubnetSubService(BaseSubnetSubService):
 
     def __init__(self, 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 .services import GCPComputeService
+from .services import GCPDnsService
 from .services import GCPNetworkingService
 from .services import GCPSecurityService
 from .services import GCPStorageService
@@ -236,14 +237,17 @@ class GCPCloudProvider(BaseCloudProvider):
         # service connections, lazily initialized
         self._gcp_compute = None
         self._gcp_storage = None
+        self._gcp_dns = None
         self._compute_resources_cache = None
         self._storage_resources_cache = None
+        self._dns_resources_cache = None
 
         # Initialize provider services
         self._compute = GCPComputeService(self)
         self._security = GCPSecurityService(self)
         self._networking = GCPNetworkingService(self)
         self._storage = GCPStorageService(self)
+        self._dns = GCPDnsService(self)
 
     # Override base class implementation because it will cause
     # an infinite loop
@@ -267,6 +271,10 @@ class GCPCloudProvider(BaseCloudProvider):
     def storage(self):
         return self._storage
 
+    @property
+    def dns(self):
+        return self._dns
+
     @property
     def gcp_compute(self):
         if not self._gcp_compute:
@@ -279,6 +287,12 @@ class GCPCloudProvider(BaseCloudProvider):
             self._gcp_storage = self._connect_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
     def _compute_resources(self):
         if not self._compute_resources_cache:
@@ -295,6 +309,14 @@ class GCPCloudProvider(BaseCloudProvider):
             self._storage_resources_cache = GCPResources(self.gcp_storage)
         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
     def _credentials(self):
         if not self.credentials_obj:
@@ -322,6 +344,10 @@ class GCPCloudProvider(BaseCloudProvider):
         return discovery.build('compute', 'v1', credentials=self._credentials,
                                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):
         args = {'project': self.project_name, 'operation': operation['name']}
         if not region and not zone:
@@ -353,6 +379,8 @@ class GCPCloudProvider(BaseCloudProvider):
             self._compute_resources.get_resource_url_with_default(
                 resource, url_or_name, **kwargs) or
             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))
         if resource_url is 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 BaseBucket
 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 BaseInstance
 from cloudbridge.base.resources import BaseInternetGateway
@@ -46,6 +48,7 @@ from cloudbridge.interfaces.resources import VolumeState
 
 from . import helpers
 from .subservices import GCPBucketObjectSubService
+from .subservices import GCPDnsRecordSubService
 from .subservices import GCPFloatingIPSubService
 from .subservices import GCPGatewaySubService
 from .subservices import GCPSubnetSubService
@@ -2024,3 +2027,71 @@ class GCPLaunchConfig(BaseLaunchConfig):
 
     def __init__(self, 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 BaseBucketService
 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 BaseGatewayService
 from cloudbridge.base.services import BaseImageService
@@ -39,6 +42,8 @@ from cloudbridge.providers.gcp import helpers
 
 from .resources import GCPBucket
 from .resources import GCPBucketObject
+from .resources import GCPDnsRecord
+from .resources import GCPDnsZone
 from .resources import GCPFirewallsDelegate
 from .resources import GCPFloatingIP
 from .resources import GCPInstance
@@ -1601,3 +1606,215 @@ class GCPFloatingIPService(BaseFloatingIPService):
                     .execute())
         self.provider.wait_for_operation(response,
                                          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
 
 from cloudbridge.base.subservices import BaseBucketObjectSubService
+from cloudbridge.base.subservices import BaseDnsRecordSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseSubnetSubService
@@ -37,3 +38,9 @@ class GCPSubnetSubService(BaseSubnetSubService):
 
     def __init__(self, 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.
 """
 from moto import mock_ec2
+from moto import mock_route53
 from moto import mock_s3
 
 import responses
@@ -37,6 +38,8 @@ class MockAWSCloudProvider(AWSCloudProvider, TestMockHelperMixin):
         self.ec2mock.start()
         self.s3mock = mock_s3()
         self.s3mock.start()
+        self.route53mock = mock_route53()
+        self.route53mock.start()
         responses.add(
             responses.GET,
             self.AWS_INSTANCE_DATA_DEFAULT_URL,
@@ -98,3 +101,4 @@ class MockAWSCloudProvider(AWSCloudProvider, TestMockHelperMixin):
         """
         self.s3mock.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 .services import OpenStackComputeService
+from .services import OpenStackDnsService
 from .services import OpenStackNetworkingService
 from .services import OpenStackSecurityService
 from .services import OpenStackStorageService
@@ -72,6 +73,7 @@ class OpenStackCloudProvider(BaseCloudProvider):
         self._networking = OpenStackNetworkingService(self)
         self._security = OpenStackSecurityService(self)
         self._storage = OpenStackStorageService(self)
+        self._dns = OpenStackDnsService(self)
 
     @property
     def nova(self):
@@ -184,6 +186,10 @@ class OpenStackCloudProvider(BaseCloudProvider):
     def storage(self):
         return self._storage
 
+    @property
+    def dns(self):
+        return self._dns
+
     def _connect_nova(self):
         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 BaseBucket
 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 BaseInstance
 from cloudbridge.base.resources import BaseInternetGateway
@@ -52,6 +54,7 @@ from cloudbridge.interfaces.resources import TrafficDirection
 from cloudbridge.interfaces.resources import VolumeState
 
 from .subservices import OpenStackBucketObjectSubService
+from .subservices import OpenStackDnsRecordSubService
 from .subservices import OpenStackFloatingIPSubService
 from .subservices import OpenStackGatewaySubService
 from .subservices import OpenStackSubnetSubService
@@ -1344,3 +1347,64 @@ class OpenStackBucket(BaseBucket):
     @property
     def objects(self):
         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 openstack.exceptions import BadRequestException
 from openstack.exceptions import HttpException
 from openstack.exceptions import NotFoundException
 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 BaseBucketService
 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 BaseGatewayService
 from cloudbridge.base.services import BaseImageService
@@ -59,6 +63,8 @@ from cloudbridge.interfaces.resources import Volume
 from . import helpers as oshelpers
 from .resources import OpenStackBucket
 from .resources import OpenStackBucketObject
+from .resources import OpenStackDnsRecord
+from .resources import OpenStackDnsZone
 from .resources import OpenStackFloatingIP
 from .resources import OpenStackInstance
 from .resources import OpenStackInternetGateway
@@ -1312,3 +1318,127 @@ class OpenStackFloatingIPService(BaseFloatingIPService):
                 log.debug("Floating IP %s not found.", fip)
                 return True
         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
 
 from cloudbridge.base.subservices import BaseBucketObjectSubService
+from cloudbridge.base.subservices import BaseDnsRecordSubService
 from cloudbridge.base.subservices import BaseFloatingIPSubService
 from cloudbridge.base.subservices import BaseGatewaySubService
 from cloudbridge.base.subservices import BaseSubnetSubService
@@ -39,3 +40,9 @@ class OpenStackSubnetSubService(BaseSubnetSubService):
 
     def __init__(self, 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'
 ]
 REQS_OPENSTACK = [
-    'openstacksdk>=0.12.0,<=0.17',
+    'openstacksdk>=0.12.0',
     'python-novaclient>=7.0.0,<=11.0',
     'python-glanceclient>=2.5.0,<=2.12',
     'python-cinderclient>=1.9.0,<=4.0',

+ 5 - 8
tests/helpers/standard_interface_tests.py

@@ -7,8 +7,6 @@ This includes:
 """
 import uuid
 
-from six.moves.urllib.parse import quote_plus
-
 import tenacity
 
 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):
     # check get
-    get_objs = service.get('tmp-' + str(uuid.uuid4()))
+    get_objs = service.get('tmp-' + str(uuid.uuid4())[:28])
     test.assertIsNone(
         get_objs,
         "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)
     test.assertIsInstance(id_property, property)
     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):

+ 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)