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

Add Azure DNS support

Implements public DNS zones and record sets for the Azure provider
via azure-mgmt-dns. Closes the long-standing gap tracked in
https://github.com/CloudVE/cloudbridge/issues/312, so the existing
cross-provider DNS integration tests now exercise Azure instead of
being skipped via has_service('dns.host_zones').

- AzureDnsService / AzureDnsZoneService / AzureDnsRecordService
- AzureDnsZone / AzureDnsRecord (admin_email round-trips in zone tags)
- AzureDnsRecordSubService
- AzureClient DNS CRUD using DnsManagementClient
- Translates FQDN record names to Azure relative names ('@' for apex)
  and strips trailing dots, which Azure rejects in zone names
- Supports A, AAAA, CNAME, MX, NS, PTR, SRV, TXT record types
Nuwan Goonasekera 18 часов назад
Родитель
Сommit
6aaf1a0c11

+ 45 - 0
cloudbridge/providers/azure/azure_client.py

@@ -18,6 +18,8 @@ from azure.mgmt.compute.models import (CreationData, Disk, DiskUpdate, Image,
                                        ImageUpdate, Snapshot, SnapshotUpdate,
                                        VirtualMachine, VirtualMachineUpdate)
 from azure.mgmt.devtestlabs.models import GalleryImageReference
+from azure.mgmt.dns import DnsManagementClient
+from azure.mgmt.dns.models import Zone
 from azure.mgmt.network import NetworkManagementClient
 from azure.mgmt.network.models import (NetworkInterface,
                                        NetworkSecurityGroup, PublicIPAddress,
@@ -180,6 +182,7 @@ class AzureClient(object):
         self._network_management_client = None
         self._subscription_client = None
         self._compute_client = None
+        self._dns_client = None
         self._access_key_result = None
         self._block_blob_service = None
         self._table_service_client = None
@@ -278,6 +281,13 @@ class AzureClient(object):
                 self._credentials, self.subscription_id)
         return self._network_management_client
 
+    @property
+    def dns_client(self):
+        if not self._dns_client:
+            self._dns_client = DnsManagementClient(
+                self._credentials, self.subscription_id)
+        return self._dns_client
+
     @property
     def blob_service(self):
         self._get_or_create_storage_account()
@@ -985,3 +995,38 @@ class AzureClient(object):
         self.network_management_client.route_tables.update_tags(
             self.resource_group, route_table_name,
             TagsObject(tags=tags))
+
+    # DNS operations
+    def get_dns_zone(self, zone_name):
+        return self.dns_client.zones.get(self.resource_group, zone_name)
+
+    def list_dns_zones(self):
+        return list(self.dns_client.zones.list_by_resource_group(
+            self.resource_group))
+
+    def create_dns_zone(self, zone_name, params):
+        return self.dns_client.zones.create_or_update(
+            self.resource_group, zone_name, Zone(**params))
+
+    def delete_dns_zone(self, zone_name):
+        self.dns_client.zones.begin_delete(
+            self.resource_group, zone_name).wait()
+
+    def get_dns_record(self, zone_name, relative_record_name, record_type):
+        return self.dns_client.record_sets.get(
+            self.resource_group, zone_name, relative_record_name, record_type)
+
+    def list_dns_records(self, zone_name):
+        return list(self.dns_client.record_sets.list_all_by_dns_zone(
+            self.resource_group, zone_name))
+
+    def create_dns_record(self, zone_name, relative_record_name,
+                          record_type, params):
+        from azure.mgmt.dns.models import RecordSet
+        return self.dns_client.record_sets.create_or_update(
+            self.resource_group, zone_name, relative_record_name,
+            record_type, RecordSet(**params))
+
+    def delete_dns_record(self, zone_name, relative_record_name, record_type):
+        self.dns_client.record_sets.delete(
+            self.resource_group, zone_name, relative_record_name, record_type)

+ 3 - 1
cloudbridge/providers/azure/provider.py

@@ -15,6 +15,7 @@ from cloudbridge.interfaces.exceptions import ProviderConnectionException
 from cloudbridge.providers.azure.azure_client import AzureClient
 
 from .services import AzureComputeService
+from .services import AzureDnsService
 from .services import AzureNetworkingService
 from .services import AzureSecurityService
 from .services import AzureStorageService
@@ -79,6 +80,7 @@ class AzureCloudProvider(BaseCloudProvider):
         self._storage = AzureStorageService(self)
         self._compute = AzureComputeService(self)
         self._networking = AzureNetworkingService(self)
+        self._dns = AzureDnsService(self)
 
     def __get_deprecated_username(self, default):
         username = self._get_config_value(
@@ -115,7 +117,7 @@ class AzureCloudProvider(BaseCloudProvider):
 
     @property
     def dns(self):
-        raise NotImplementedError()
+        return self._dns
 
     @property
     def azure_client(self):

+ 116 - 1
cloudbridge/providers/azure/resources.py

@@ -7,7 +7,8 @@ import logging
 
 import paramiko
 from cloudbridge.base.resources import (BaseAttachmentInfo, BaseBucket,
-                                        BaseBucketObject, BaseFloatingIP,
+                                        BaseBucketObject, BaseDnsRecord,
+                                        BaseDnsZone, BaseFloatingIP,
                                         BaseInstance, BaseInternetGateway,
                                         BaseKeyPair, BaseLaunchConfig,
                                         BaseMachineImage, BaseNetwork,
@@ -30,6 +31,7 @@ from azure.mgmt.network.models import NetworkSecurityGroup
 
 from . import helpers as azure_helpers
 from .subservices import (AzureBucketObjectSubService,
+                          AzureDnsRecordSubService,
                           AzureFloatingIPSubService, AzureGatewaySubService,
                           AzureSubnetSubService, AzureVMFirewallRuleSubService)
 
@@ -1554,3 +1556,116 @@ class AzureInternetGateway(BaseInternetGateway):
     @property
     def floating_ips(self):
         return self._fips_container
+
+
+# Map Azure record-set type suffix (e.g. 'Microsoft.Network/dnszones/A')
+# to cloudbridge DnsRecordType. Used to expose record data in a normalized form.
+_AZURE_RECORD_TYPE_ATTR = {
+    'A': 'a_records',
+    'AAAA': 'aaaa_records',
+    'CNAME': 'cname_record',
+    'MX': 'mx_records',
+    'NS': 'ns_records',
+    'PTR': 'ptr_records',
+    'SRV': 'srv_records',
+    'TXT': 'txt_records',
+}
+
+
+def _azure_record_type(raw_record):
+    """Return the bare type (e.g. 'A') from an Azure RecordSet."""
+    rec_type = raw_record.type or ''
+    # Azure formats: 'Microsoft.Network/dnszones/A' or bare 'A'
+    return rec_type.split('/')[-1] if '/' in rec_type else rec_type
+
+
+def _azure_record_data(raw_record):
+    """Extract the data values from an Azure RecordSet as a list of strings."""
+    rt = _azure_record_type(raw_record)
+    attr = _AZURE_RECORD_TYPE_ATTR.get(rt)
+    if not attr:
+        return []
+    value = getattr(raw_record, attr, None)
+    if value is None:
+        return []
+    if rt == 'A':
+        return [r.ipv4_address for r in value]
+    if rt == 'AAAA':
+        return [r.ipv6_address for r in value]
+    if rt == 'CNAME':
+        return [value.cname]
+    if rt == 'MX':
+        return ['{0} {1}'.format(r.preference, r.exchange) for r in value]
+    if rt == 'NS':
+        return [r.nsdname for r in value]
+    if rt == 'PTR':
+        return [r.ptrdname for r in value]
+    if rt == 'SRV':
+        return ['{0} {1} {2} {3}'.format(r.priority, r.weight, r.port,
+                                         r.target) for r in value]
+    if rt == 'TXT':
+        # Each TXT record carries a list of strings; join with spaces by convention
+        return [' '.join(r.value) if isinstance(r.value, list) else r.value
+                for r in value]
+    return []
+
+
+class AzureDnsZone(BaseDnsZone):
+
+    def __init__(self, provider, dns_zone):
+        super(AzureDnsZone, self).__init__(provider)
+        self._dns_zone = dns_zone
+        self._dns_record_container = AzureDnsRecordSubService(provider, self)
+
+    @property
+    def id(self):
+        return self._dns_zone.name
+
+    @property
+    def name(self):
+        return self._dns_zone.name
+
+    @property
+    def admin_email(self):
+        tags = self._dns_zone.tags or {}
+        return tags.get('admin_email')
+
+    @property
+    def records(self):
+        return self._dns_record_container
+
+
+class AzureDnsRecord(BaseDnsRecord):
+
+    def __init__(self, provider, dns_zone, dns_record):
+        super(AzureDnsRecord, self).__init__(provider)
+        self._dns_zone = dns_zone
+        self._dns_rec = dns_record
+
+    @property
+    def id(self):
+        return self.name + ":" + self.type
+
+    @property
+    def name(self):
+        return self._dns_rec.name
+
+    @property
+    def zone_id(self):
+        return self._dns_zone.id
+
+    @property
+    def type(self):
+        return _azure_record_type(self._dns_rec)
+
+    @property
+    def data(self):
+        return _azure_record_data(self._dns_rec)
+
+    @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)

+ 196 - 4
cloudbridge/providers/azure/services.py

@@ -8,6 +8,8 @@ from cloudbridge.base.resources import (ClientPagedResultList,
                                         ServerPagedResultList)
 from cloudbridge.base.services import (BaseBucketObjectService,
                                        BaseBucketService, BaseComputeService,
+                                       BaseDnsRecordService, BaseDnsService,
+                                       BaseDnsZoneService,
                                        BaseFloatingIPService,
                                        BaseGatewayService, BaseImageService,
                                        BaseInstanceService, BaseKeyPairService,
@@ -40,10 +42,11 @@ from azure.mgmt.network.models import (AddressSpace,
                                        PublicIPAddressSku,
                                        PublicIPAddressSkuName, SubResource)
 
-from .resources import (AzureBucket, AzureBucketObject, AzureFloatingIP,
-                        AzureInstance, AzureInternetGateway, AzureKeyPair,
-                        AzureLaunchConfig, AzureMachineImage, AzureNetwork,
-                        AzureRegion, AzureRouter, AzureSnapshot, AzureSubnet,
+from .resources import (AzureBucket, AzureBucketObject, AzureDnsRecord,
+                        AzureDnsZone, AzureFloatingIP, AzureInstance,
+                        AzureInternetGateway, AzureKeyPair, AzureLaunchConfig,
+                        AzureMachineImage, AzureNetwork, AzureRegion,
+                        AzureRouter, AzureSnapshot, AzureSubnet,
                         AzureVMFirewall, AzureVMFirewallRule, AzureVMType,
                         AzureVolume)
 
@@ -1375,3 +1378,192 @@ class AzureFloatingIPService(BaseFloatingIPService):
     def delete(self, gateway, fip):
         fip_id = fip.id if isinstance(fip, AzureFloatingIP) else fip
         self.provider.azure_client.delete_floating_ip(fip_id)
+
+
+def _strip_trailing_dot(name):
+    return name[:-1] if name and name.endswith('.') else name
+
+
+def _to_relative_record_name(fqdn, zone_name):
+    """Translate a cloudbridge FQDN record name to Azure's relative form.
+
+    Azure's record set API works with names relative to the zone (e.g.
+    ``foo`` inside ``example.com``) plus the special ``@`` token for the
+    zone apex. cloudbridge callers pass either the bare zone (apex) or a
+    dotted FQDN such as ``foo.example.com.``.
+    """
+    name = _strip_trailing_dot(fqdn) if fqdn else ''
+    zone = _strip_trailing_dot(zone_name) if zone_name else ''
+    if not name or name == zone:
+        return '@'
+    suffix = '.' + zone
+    if name.endswith(suffix):
+        return name[: -len(suffix)] or '@'
+    return name
+
+
+class AzureDnsService(BaseDnsService):
+
+    def __init__(self, provider):
+        super(AzureDnsService, self).__init__(provider)
+
+        # Initialize provider services
+        self._zone_svc = AzureDnsZoneService(self.provider)
+        self._record_svc = AzureDnsRecordService(self.provider)
+
+    @property
+    def host_zones(self):
+        return self._zone_svc
+
+    @property
+    def _records(self):
+        return self._record_svc
+
+
+class AzureDnsZoneService(BaseDnsZoneService):
+
+    def __init__(self, provider):
+        super(AzureDnsZoneService, self).__init__(provider)
+
+    @dispatch(event="provider.dns.host_zones.get",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def get(self, dns_zone_id):
+        try:
+            zone = self.provider.azure_client.get_dns_zone(
+                _strip_trailing_dot(dns_zone_id))
+            return AzureDnsZone(self.provider, zone)
+        except ResourceNotFoundError:
+            return None
+
+    @dispatch(event="provider.dns.host_zones.list",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def list(self, limit=None, marker=None):
+        zones = [AzureDnsZone(self.provider, z)
+                 for z in self.provider.azure_client.list_dns_zones()]
+        return ClientPagedResultList(self.provider, zones, limit, 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):
+        AzureDnsZone.assert_valid_resource_name(name)
+        zone_name = _strip_trailing_dot(name)
+        params = {
+            # DNS zones in Azure are global resources but the API still
+            # requires location='global'.
+            'location': 'global',
+            'tags': {'admin_email': admin_email},
+        }
+        zone = self.provider.azure_client.create_dns_zone(zone_name, params)
+        return AzureDnsZone(self.provider, zone)
+
+    @dispatch(event="provider.dns.host_zones.delete",
+              priority=BaseDnsZoneService.STANDARD_EVENT_PRIORITY)
+    def delete(self, dns_zone):
+        zone_name = (dns_zone.id if isinstance(dns_zone, AzureDnsZone)
+                     else dns_zone)
+        self.provider.azure_client.delete_dns_zone(
+            _strip_trailing_dot(zone_name))
+
+
+class AzureDnsRecordService(BaseDnsRecordService):
+
+    def __init__(self, provider):
+        super(AzureDnsRecordService, self).__init__(provider)
+
+    def _to_record_params(self, rec_type, data, ttl):
+        """Translate cloudbridge data to Azure record-set parameters."""
+        # Local imports keep the module importable when azure-mgmt-dns
+        # isn't installed (e.g. on AWS-only test environments).
+        from azure.mgmt.dns.models import (AaaaRecord, ARecord, CnameRecord,
+                                           MxRecord, NsRecord, PtrRecord,
+                                           SrvRecord, TxtRecord)
+
+        values = data if isinstance(data, list) else [data]
+        params = {'ttl': ttl or 300}
+
+        if rec_type == 'A':
+            params['a_records'] = [ARecord(ipv4_address=v) for v in values]
+        elif rec_type == 'AAAA':
+            params['aaaa_records'] = [
+                AaaaRecord(ipv6_address=v) for v in values]
+        elif rec_type == 'CNAME':
+            # CNAME is a single-valued record in Azure.
+            params['cname_record'] = CnameRecord(
+                cname=self._standardize_record(values[0], rec_type))
+        elif rec_type == 'MX':
+            mx = []
+            for v in values:
+                preference, exchange = v.split(' ', 1)
+                mx.append(MxRecord(
+                    preference=int(preference),
+                    exchange=self._standardize_record(exchange.strip(),
+                                                      rec_type)))
+            params['mx_records'] = mx
+        elif rec_type == 'NS':
+            params['ns_records'] = [NsRecord(nsdname=v) for v in values]
+        elif rec_type == 'PTR':
+            params['ptr_records'] = [PtrRecord(ptrdname=v) for v in values]
+        elif rec_type == 'SRV':
+            srv = []
+            for v in values:
+                priority, weight, port, target = v.split(' ', 3)
+                srv.append(SrvRecord(
+                    priority=int(priority), weight=int(weight),
+                    port=int(port), target=target))
+            params['srv_records'] = srv
+        elif rec_type == 'TXT':
+            params['txt_records'] = [
+                TxtRecord(value=v if isinstance(v, list) else [v])
+                for v in values]
+        else:
+            raise InvalidParamException(
+                "Unsupported DNS record type: %s" % rec_type)
+        return params
+
+    def get(self, dns_zone, rec_id):
+        if not rec_id or ':' not in rec_id:
+            return None
+        rec_name, rec_type = rec_id.split(':', 1)
+        try:
+            rec = self.provider.azure_client.get_dns_record(
+                dns_zone.id, rec_name, rec_type)
+            return AzureDnsRecord(self.provider, dns_zone, rec)
+        except ResourceNotFoundError:
+            return None
+
+    def list(self, dns_zone, limit=None, marker=None):
+        records = [AzureDnsRecord(self.provider, dns_zone, r)
+                   for r in self.provider.azure_client.list_dns_records(
+                       dns_zone.id)]
+        return ClientPagedResultList(self.provider, records, limit, 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):
+        AzureDnsRecord.assert_valid_resource_name(name)
+        relative_name = _to_relative_record_name(name, dns_zone.id)
+        params = self._to_record_params(type, data, ttl)
+        self.provider.azure_client.create_dns_record(
+            dns_zone.id, relative_name, type, params)
+        return self.get(dns_zone, relative_name + ':' + type)
+
+    def delete(self, dns_zone, record):
+        if isinstance(record, AzureDnsRecord):
+            rec_name = record.name
+            rec_type = record.type
+        else:
+            rec_name, rec_type = record.split(':', 1)
+        self.provider.azure_client.delete_dns_record(
+            dns_zone.id, rec_name, rec_type)

+ 7 - 0
cloudbridge/providers/azure/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
@@ -36,3 +37,9 @@ class AzureSubnetSubService(BaseSubnetSubService):
 
     def __init__(self, provider, network):
         super(AzureSubnetSubService, self).__init__(provider, network)
+
+
+class AzureDnsRecordSubService(BaseDnsRecordSubService):
+
+    def __init__(self, provider, dns_zone):
+        super(AzureDnsRecordSubService, self).__init__(provider, dns_zone)

+ 1 - 0
pyproject.toml

@@ -55,6 +55,7 @@ azure = [
     "azure-mgmt-resource>=23.0.0,<26.0.0",
     "azure-mgmt-subscription>=3.0.0,<4.0.0",
     "azure-mgmt-compute>=34.0.0,<39.0.0",
+    "azure-mgmt-dns>=9.0.0,<10.0.0",
     "azure-mgmt-network>=28.0.0,<31.0.0",
     "azure-mgmt-storage>=22.0.0,<25.0.0",
     "azure-storage-blob>=12.20.0,<13.0.0",