# # # from __future__ import absolute_import, division, print_function, \ unicode_literals from azure.common.credentials import ServicePrincipalCredentials from azure.mgmt.dns import DnsManagementClient from msrestazure.azure_exceptions import CloudError from azure.mgmt.dns.models import ARecord, AaaaRecord, CnameRecord, MxRecord, \ SrvRecord, NsRecord, PtrRecord, TxtRecord, Zone import logging from functools import reduce from ..record import Record from .base import BaseProvider class _AzureRecord(object): '''Wrapper for OctoDNS record for AzureProvider to make dns_client calls. azuredns.py: class: octodns.provider.azuredns._AzureRecord An _AzureRecord is easily accessible to Azure DNS Management library functions and is used to wrap all relevant data to create a record in Azure. ''' TYPE_MAP = { 'A': ARecord, 'AAAA': AaaaRecord, 'CNAME': CnameRecord, 'MX': MxRecord, 'SRV': SrvRecord, 'NS': NsRecord, 'PTR': PtrRecord, 'TXT': TxtRecord } def __init__(self, resource_group, record, delete=False): '''Constructor for _AzureRecord. Notes on Azure records: An Azure record set has the form RecordSet(name=<...>, type=<...>, arecords=[...], aaaa_records, ..) When constructing an azure record as done in self._apply_Create, the argument parameters for an A record would be parameters={'ttl': , 'arecords': [ARecord(),]}. As another example for CNAME record: parameters={'ttl': , 'cname_record': CnameRecord()}. Below, key_name and class_name are the dictionary key and Azure Record class respectively. :param resource_group: The name of resource group in Azure :type resource_group: str :param record: An OctoDNS record :type record: ..record.Record :param delete: If true, omit data parsing; not needed to delete :type delete: bool :type return: _AzureRecord ''' self.resource_group = resource_group self.zone_name = record.zone.name[:len(record.zone.name) - 1] self.relative_record_set_name = record.name or '@' self.record_type = record._type if delete: return # Refer to function docstring for key_name and class_name. format_u_s = '' if record._type == 'A' else '_' key_name = '{}{}records'.format(self.record_type, format_u_s).lower() if record._type == 'CNAME': key_name = key_name[:len(key_name) - 1] azure_class = self.TYPE_MAP[self.record_type] self.params = getattr(self, '_params_for_{}'.format(record._type)) self.params = self.params(record.data, key_name, azure_class) self.params['ttl'] = record.ttl def _params(self, data, key_name, azure_class): try: values = data['values'] except KeyError: values = [data['value']] return {key_name: [azure_class(v) for v in values]} _params_for_A = _params _params_for_AAAA = _params _params_for_NS = _params _params_for_PTR = _params def _params_for_CNAME(self, data, key_name, azure_class): return {key_name: azure_class(data['value'])} def _params_for_MX(self, data, key_name, azure_class): params = [] if 'values' in data: for vals in data['values']: params.append(azure_class(vals['preference'], vals['exchange'])) else: # Else there is a singular data point keyed by 'value'. params.append(azure_class(data['value']['preference'], data['value']['exchange'])) return {key_name: params} def _params_for_SRV(self, data, key_name, azure_class): params = [] if 'values' in data: for vals in data['values']: params.append(azure_class(vals['priority'], vals['weight'], vals['port'], vals['target'])) else: # Else there is a singular data point keyed by 'value'. params.append(azure_class(data['value']['priority'], data['value']['weight'], data['value']['port'], data['value']['target'])) return {key_name: params} def _params_for_TXT(self, data, key_name, azure_class): try: # API for TxtRecord has list of str, even for singleton values = data['values'] except KeyError: values = [data['value']] return {key_name: [azure_class([v]) for v in values]} def _equals(self, b): '''Checks whether two records are equal by comparing all fields. :param b: Another _AzureRecord object :type b: _AzureRecord :type return: bool ''' def parse_dict(params): vals = [] for char in params: if char != 'ttl': list_records = params[char] try: for record in list_records: vals.append(record.__dict__) except: vals.append(list_records.__dict__) vals.sort() return vals return (self.resource_group == b.resource_group) & \ (self.zone_name == b.zone_name) & \ (self.record_type == b.record_type) & \ (self.params['ttl'] == b.params['ttl']) & \ (parse_dict(self.params) == parse_dict(b.params)) & \ (self.relative_record_set_name == b.relative_record_set_name) def __str__(self): '''String representation of an _AzureRecord. :type return: str ''' string = 'Zone: {}; '.format(self.zone_name) string += 'Name: {}; '.format(self.relative_record_set_name) string += 'Type: {}; '.format(self.record_type) if not hasattr(self, 'params'): return string string += 'Ttl: {}; '.format(self.params['ttl']) for char in self.params: if char != 'ttl': try: for rec in self.params[char]: string += 'Record: {}; '.format(rec.__dict__) except: string += 'Record: {}; '.format(self.params[char].__dict__) return string def _check_endswith_dot(string): return string if string.endswith('.') else string + '.' def _parse_azure_type(string): '''Converts string representing an Azure RecordSet type to usual type. :param string: the Azure type. eg: :type string: str :type return: str ''' return string.split('/')[len(string.split('/')) - 1] class AzureProvider(BaseProvider): ''' Azure DNS Provider azuredns.py: class: octodns.provider.azuredns.AzureProvider # Current support of authentication of access to Azure services only # includes using a Service Principal: # https://docs.microsoft.com/en-us/azure/azure-resource-manager/ # resource-group-create-service-principal-portal # The Azure Active Directory Application ID (aka client ID): client_id: # Authentication Key Value: (note this should be secret) key: # Directory ID (aka tenant ID): directory_id: # Subscription ID: sub_id: # Resource Group name: resource_group: # All are required to authenticate. Example config file with variables: " --- providers: config: class: octodns.provider.yaml.YamlProvider directory: ./config (example path to directory of zone files) azuredns: class: octodns.provider.azuredns.AzureProvider client_id: env/AZURE_APPLICATION_ID key: env/AZURE_AUTHENTICATION_KEY directory_id: env/AZURE_DIRECTORY_ID sub_id: env/AZURE_SUBSCRIPTION_ID resource_group: 'TestResource1' zones: example.com.: sources: - config targets: - azuredns " The first four variables above can be hidden in environment variables and octoDNS will automatically search for them in the shell. It is possible to also hard-code into the config file: eg, resource_group. ''' SUPPORTS_GEO = False SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SRV', 'TXT')) def __init__(self, id, client_id, key, directory_id, sub_id, resource_group, *args, **kwargs): self.log = logging.getLogger('AzureProvider[{}]'.format(id)) self.log.debug('__init__: id=%s, client_id=%s, ' 'key=***, directory_id:%s', id, client_id, directory_id) super(AzureProvider, self).__init__(id, *args, **kwargs) credentials = ServicePrincipalCredentials( client_id, secret=key, tenant=directory_id ) self._dns_client = DnsManagementClient(credentials, sub_id) self._resource_group = resource_group self._azure_zones = set() def _populate_zones(self): self.log.debug('azure_zones: loading') list_zones = self._dns_client.zones.list_by_resource_group for zone in list_zones(self._resource_group): self._azure_zones.add(zone.name) def _check_zone(self, name, create=False): '''Checks whether a zone specified in a source exist in Azure server. Note that Azure zones omit end '.' eg: contoso.com vs contoso.com. Returns the name if it exists. :param name: Name of a zone to checks :type name: str :param create: If True, creates the zone of that name. :type create: bool :type return: str or None ''' self.log.debug('_check_zone: name=%s', name) try: if name in self._azure_zones: return name self._dns_client.zones.get(self._resource_group, name) self._azure_zones.add(name) return name except CloudError as err: msg = 'The Resource \'Microsoft.Network/dnszones/{}\''.format(name) msg += ' under resource group \'{}\''.format(self._resource_group) msg += ' was not found.' if msg == err.message: # Then the only error is that the zone doesn't currently exist if create: self.log.debug('_check_zone:no matching zone; creating %s', name) create_zone = self._dns_client.zones.create_or_update create_zone(self._resource_group, name, Zone('global')) return name else: return raise def populate(self, zone, target=False, lenient=False): '''Required function of manager.py to collect records from zone. Special notes for Azure. Azure zone names omit final '.' Azure root records names are represented by '@'. OctoDNS uses '' Azure records created through online interface may have null values (eg, no IP address for A record). Azure online interface allows constructing records with null values which are destroyed by _apply. Specific quirks such as these are responsible for any non-obvious parsing in this function and the functions '_params_for_*'. :param zone: A dns zone :type zone: octodns.zone.Zone :param target: Checks if Azure is source or target of config. Currently only supports as a target. Unused. :type target: bool :param lenient: Unused. Check octodns.manager for usage. :type lenient: bool :type return: void ''' self.log.debug('populate: name=%s', zone.name) exists = False before = len(zone.records) zone_name = zone.name[:len(zone.name) - 1] self._populate_zones() self._check_zone(zone_name) _records = set() records = self._dns_client.record_sets.list_by_dns_zone if self._check_zone(zone_name): exists = True for azrecord in records(self._resource_group, zone_name): if _parse_azure_type(azrecord.type) in self.SUPPORTS: _records.add(azrecord) for azrecord in _records: record_name = azrecord.name if azrecord.name != '@' else '' typ = _parse_azure_type(azrecord.type) data = getattr(self, '_data_for_{}'.format(typ)) data = data(azrecord) data['type'] = typ data['ttl'] = azrecord.ttl record = Record.new(zone, record_name, data, source=self) zone.add_record(record) self.log.info('populate: found %s records, exists=%s', len(zone.records) - before, exists) return exists def _data_for_A(self, azrecord): return {'values': [ar.ipv4_address for ar in azrecord.arecords]} def _data_for_AAAA(self, azrecord): return {'values': [ar.ipv6_address for ar in azrecord.aaaa_records]} def _data_for_CNAME(self, azrecord): '''Parsing data from Azure DNS Client record call :param azrecord: a return of a call to list azure records :type azrecord: azure.mgmt.dns.models.RecordSet :type return: dict CNAME and PTR both use the catch block to catch possible empty records. Refer to population comment. ''' try: return {'value': _check_endswith_dot(azrecord.cname_record.cname)} except: return {'value': '.'} def _data_for_MX(self, azrecord): return {'values': [{'preference': ar.preference, 'exchange': ar.exchange} for ar in azrecord.mx_records]} def _data_for_NS(self, azrecord): vals = [ar.nsdname for ar in azrecord.ns_records] return {'values': [_check_endswith_dot(val) for val in vals]} def _data_for_PTR(self, azrecord): try: ptrdname = azrecord.ptr_records[0].ptrdname return {'value': _check_endswith_dot(ptrdname)} except: return {'value': '.'} def _data_for_SRV(self, azrecord): return {'values': [{'priority': ar.priority, 'weight': ar.weight, 'port': ar.port, 'target': ar.target} for ar in azrecord.srv_records]} def _data_for_TXT(self, azrecord): return {'values': [reduce((lambda a, b: a + b), ar.value) for ar in azrecord.txt_records]} def _apply_Create(self, change): '''A record from change must be created. :param change: a change object :type change: octodns.record.Change :type return: void ''' ar = _AzureRecord(self._resource_group, change.new) create = self._dns_client.record_sets.create_or_update create(resource_group_name=ar.resource_group, zone_name=ar.zone_name, relative_record_set_name=ar.relative_record_set_name, record_type=ar.record_type, parameters=ar.params) self.log.debug('* Success Create/Update: {}'.format(ar)) _apply_Update = _apply_Create def _apply_Delete(self, change): ar = _AzureRecord(self._resource_group, change.existing, delete=True) delete = self._dns_client.record_sets.delete delete(self._resource_group, ar.zone_name, ar.relative_record_set_name, ar.record_type) self.log.debug('* Success Delete: {}'.format(ar)) def _apply(self, plan): '''Required function of manager.py to actually apply a record change. :param plan: Contains the zones and changes to be made :type plan: octodns.provider.base.Plan :type return: void ''' desired = plan.desired changes = plan.changes self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, len(changes)) azure_zone_name = desired.name[:len(desired.name) - 1] self._check_zone(azure_zone_name, create=True) for change in changes: class_name = change.__class__.__name__ getattr(self, '_apply_{}'.format(class_name))(change)