From 6aaf1a0c118060e0ca8b509463a29bc1e5bb7db3 Mon Sep 17 00:00:00 2001 From: Nuwan Goonasekera <2070605+nuwang@users.noreply.github.com> Date: Wed, 20 May 2026 22:49:58 +0530 Subject: [PATCH] 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 --- cloudbridge/providers/azure/azure_client.py | 45 +++++ cloudbridge/providers/azure/provider.py | 4 +- cloudbridge/providers/azure/resources.py | 117 +++++++++++- cloudbridge/providers/azure/services.py | 200 +++++++++++++++++++- cloudbridge/providers/azure/subservices.py | 7 + pyproject.toml | 1 + 6 files changed, 368 insertions(+), 6 deletions(-) diff --git a/cloudbridge/providers/azure/azure_client.py b/cloudbridge/providers/azure/azure_client.py index 9dbfb228..d4e40173 100644 --- a/cloudbridge/providers/azure/azure_client.py +++ b/cloudbridge/providers/azure/azure_client.py @@ -18,6 +18,8 @@ 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 @@ def __init__(self, config): 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 @@ def network_management_client(self): 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 @@ def update_route_table_tags(self, route_table_name, tags): 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) diff --git a/cloudbridge/providers/azure/provider.py b/cloudbridge/providers/azure/provider.py index cd59fc9a..b1bc4357 100644 --- a/cloudbridge/providers/azure/provider.py +++ b/cloudbridge/providers/azure/provider.py @@ -15,6 +15,7 @@ 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 @@ def __init__(self, config): 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 @@ def storage(self): @property def dns(self): - raise NotImplementedError() + return self._dns @property def azure_client(self): diff --git a/cloudbridge/providers/azure/resources.py b/cloudbridge/providers/azure/resources.py index 2c683a68..150e0b12 100644 --- a/cloudbridge/providers/azure/resources.py +++ b/cloudbridge/providers/azure/resources.py @@ -7,7 +7,8 @@ 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 . import helpers as azure_helpers from .subservices import (AzureBucketObjectSubService, + AzureDnsRecordSubService, AzureFloatingIPSubService, AzureGatewaySubService, AzureSubnetSubService, AzureVMFirewallRuleSubService) @@ -1554,3 +1556,116 @@ def delete(self): @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) diff --git a/cloudbridge/providers/azure/services.py b/cloudbridge/providers/azure/services.py index e7a6a0c3..3a340d00 100644 --- a/cloudbridge/providers/azure/services.py +++ b/cloudbridge/providers/azure/services.py @@ -8,6 +8,8 @@ ServerPagedResultList) from cloudbridge.base.services import (BaseBucketObjectService, BaseBucketService, BaseComputeService, + BaseDnsRecordService, BaseDnsService, + BaseDnsZoneService, BaseFloatingIPService, BaseGatewayService, BaseImageService, BaseInstanceService, BaseKeyPairService, @@ -40,10 +42,11 @@ 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 @@ def create(self, gateway): 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) diff --git a/cloudbridge/providers/azure/subservices.py b/cloudbridge/providers/azure/subservices.py index e637652f..ef724c09 100644 --- a/cloudbridge/providers/azure/subservices.py +++ b/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) diff --git a/pyproject.toml b/pyproject.toml index 964de431..6ef2adb0 100644 --- a/pyproject.toml +++ b/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",