# # # from __future__ import absolute_import, division, print_function, \ unicode_literals from collections import defaultdict from azure.identity import ClientSecretCredential from azure.common.credentials import ServicePrincipalCredentials from azure.mgmt.dns import DnsManagementClient from azure.mgmt.trafficmanager import TrafficManagerManagementClient from azure.mgmt.dns.models import ARecord, AaaaRecord, CaaRecord, \ CnameRecord, MxRecord, SrvRecord, NsRecord, PtrRecord, TxtRecord, Zone from azure.mgmt.trafficmanager.models import Profile, DnsConfig, \ MonitorConfig, Endpoint, MonitorConfigCustomHeadersItem import logging from functools import reduce from ..record import Record, Update, GeoCodes from .base import BaseProvider class AzureException(Exception): pass def escape_semicolon(s): assert s return s.replace(';', '\\;') def unescape_semicolon(s): assert s return s.replace('\\;', ';') def azure_chunked_value(val): CHUNK_SIZE = 255 val_replace = val.replace('"', '\\"') value = unescape_semicolon(val_replace) if len(val) > CHUNK_SIZE: vs = [value[i:i + CHUNK_SIZE] for i in range(0, len(value), CHUNK_SIZE)] else: vs = value return vs def azure_chunked_values(s): values = [] for v in s: values.append(azure_chunked_value(v)) return values 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, 'CAA': CaaRecord, 'CNAME': CnameRecord, 'MX': MxRecord, 'SRV': SrvRecord, 'NS': NsRecord, 'PTR': PtrRecord, 'TXT': TxtRecord } def __init__(self, resource_group, record, delete=False, traffic_manager=None): '''Constructor for _AzureRecord. Notes on Azure records: An Azure record set has the form RecordSet(name=<...>, type=<...>, a_records=[...], aaaa_records=[...], ...) When constructing an azure record as done in self._apply_Create, the argument parameters for an A record would be parameters={'ttl': , 'a_records': [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.log = logging.getLogger('AzureRecord') self.resource_group = resource_group self.zone_name = record.zone.name[:-1] self.relative_record_set_name = record.name or '@' self.record_type = record._type self._record = record self.traffic_manager = traffic_manager if delete: return # Refer to function docstring for key_name and class_name. key_name = '{}_records'.format(self.record_type).lower() if record._type == 'CNAME': key_name = key_name[:-1] azure_class = self.TYPE_MAP[self.record_type] params_for = getattr(self, '_params_for_{}'.format(record._type)) self.params = params_for(record.data, key_name, azure_class) self.params['ttl'] = record.ttl def _params_for_A(self, data, key_name, azure_class): try: values = data['values'] except KeyError: values = [data['value']] return {key_name: [azure_class(ipv4_address=v) for v in values]} def _params_for_AAAA(self, data, key_name, azure_class): try: values = data['values'] except KeyError: values = [data['value']] return {key_name: [azure_class(ipv6_address=v) for v in values]} def _params_for_CAA(self, data, key_name, azure_class): params = [] if 'values' in data: for vals in data['values']: params.append(azure_class(flags=vals['flags'], tag=vals['tag'], value=vals['value'])) else: # Else there is a singular data point keyed by 'value'. params.append(azure_class(flags=data['value']['flags'], tag=data['value']['tag'], value=data['value']['value'])) return {key_name: params} def _params_for_CNAME(self, data, key_name, azure_class): if self._record.dynamic and self.traffic_manager: return {'target_resource': self.traffic_manager} return {key_name: azure_class(cname=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(preference=vals['preference'], exchange=vals['exchange'])) else: # Else there is a singular data point keyed by 'value'. params.append(azure_class(preference=data['value']['preference'], exchange=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(priority=vals['priority'], weight=vals['weight'], port=vals['port'], target=vals['target'])) else: # Else there is a singular data point keyed by 'value'. params.append(azure_class(priority=data['value']['priority'], weight=data['value']['weight'], port=data['value']['port'], target=data['value']['target'])) return {key_name: params} def _params_for_NS(self, data, key_name, azure_class): try: values = data['values'] except KeyError: values = [data['value']] return {key_name: [azure_class(nsdname=v) for v in values]} def _params_for_PTR(self, data, key_name, azure_class): try: values = data['values'] except KeyError: values = [data['value']] return {key_name: [azure_class(ptrdname=v) for v in values]} def _params_for_TXT(self, data, key_name, azure_class): params = [] try: # API for TxtRecord has list of str, even for singleton values = [v for v in azure_chunked_values(data['values'])] except KeyError: values = [azure_chunked_value(data['value'])] for v in values: if isinstance(v, list): params.append(azure_class(value=v)) else: params.append(azure_class(value=[v])) return {key_name: params} 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 key_dict(d): return sum([hash('{}:{}'.format(k, v)) for k, v in d.items()]) 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(key=key_dict) 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 _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('/')[-1] def _traffic_manager_suffix(record): return record.fqdn[:-1].replace('.', '-') def _get_monitor(record): monitor = MonitorConfig( protocol=record.healthcheck_protocol, port=record.healthcheck_port, path=record.healthcheck_path, ) host = record.healthcheck_host if host: monitor.custom_headers = [MonitorConfigCustomHeadersItem( name='Host', value=host )] return monitor def _profile_is_match(have, desired): if have is None or desired is None: return False # compare basic attributes if have.name != desired.name or \ have.traffic_routing_method != desired.traffic_routing_method or \ have.dns_config.ttl != desired.dns_config.ttl or \ len(have.endpoints) != len(desired.endpoints): return False # compare monitoring configuration monitor_have = have.monitor_config monitor_desired = desired.monitor_config if monitor_have.protocol != monitor_desired.protocol or \ monitor_have.port != monitor_desired.port or \ monitor_have.path != monitor_desired.path or \ monitor_have.custom_headers != monitor_desired.custom_headers: return False # compare endpoints method = have.traffic_routing_method if method == 'Priority': have_endpoints = sorted(have.endpoints, key=lambda e: e.priority) desired_endpoints = sorted(desired.endpoints, key=lambda e: e.priority) elif method == 'Weighted': have_endpoints = sorted(have.endpoints, key=lambda e: e.target) desired_endpoints = sorted(desired.endpoints, key=lambda e: e.target) else: have_endpoints = have.endpoints desired_endpoints = desired.endpoints endpoints = zip(have_endpoints, desired_endpoints) for have_endpoint, desired_endpoint in endpoints: if have_endpoint.name != desired_endpoint.name or \ have_endpoint.type != desired_endpoint.type: return False target_type = have_endpoint.type.split('/')[-1] if target_type == 'externalEndpoints': # compare value, weight, priority if have_endpoint.target != desired_endpoint.target: return False if method == 'Weighted' and \ have_endpoint.weight != desired_endpoint.weight: return False elif target_type == 'nestedEndpoints': # compare targets if have_endpoint.target_resource_id != \ desired_endpoint.target_resource_id: return False # compare geos if method == 'Geographic': have_geos = sorted(have_endpoint.geo_mapping) desired_geos = sorted(desired_endpoint.geo_mapping) if have_geos != desired_geos: return False else: # unexpected, give up return False return True 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_DYNAMIC = True SUPPORTS = set(('A', 'AAAA', 'CAA', '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) # Store necessary initialization params self._dns_client_handle = None self._dns_client_client_id = client_id self._dns_client_key = key self._dns_client_directory_id = directory_id self._dns_client_subscription_id = sub_id self.__dns_client = None self.__tm_client = None self._resource_group = resource_group self._azure_zones = set() self._traffic_managers = dict() @property def _dns_client(self): if self.__dns_client is None: # Azure's logger spits out a lot of debug messages at 'INFO' # level, override it by re-assigning `info` method to `debug` # (ugly hack until I find a better way) logger_name = 'azure.core.pipeline.policies.http_logging_policy' logger = logging.getLogger(logger_name) logger.info = logger.debug self.__dns_client = DnsManagementClient( credential=ClientSecretCredential( client_id=self._dns_client_client_id, client_secret=self._dns_client_key, tenant_id=self._dns_client_directory_id, logger=logger, ), subscription_id=self._dns_client_subscription_id, ) return self.__dns_client @property def _tm_client(self): if self.__tm_client is None: self.__tm_client = TrafficManagerManagementClient( ServicePrincipalCredentials( self._dns_client_client_id, secret=self._dns_client_key, tenant=self._dns_client_directory_id, ), self._dns_client_subscription_id, ) return self.__tm_client 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.rstrip('.')) 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 create=%s', name, create) # Check if the zone already exists in our set if name in self._azure_zones: return name # If not, and its time to create, lets do it. 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(location='global')) self._azure_zones.add(name) return name else: # Else return nothing (aka false) return def _populate_traffic_managers(self): self.log.debug('traffic managers: loading') list_profiles = self._tm_client.profiles.list_by_resource_group for profile in list_profiles(self._resource_group): self._traffic_managers[profile.id] = profile # link nested profiles in advance for convenience for _, profile in self._traffic_managers.items(): self._populate_nested_profiles(profile) def _populate_nested_profiles(self, profile): for ep in profile.endpoints: target_id = ep.target_resource_id if target_id and target_id in self._traffic_managers: target = self._traffic_managers[target_id] ep.target_resource = self._populate_nested_profiles(target) return profile def _get_tm_profile_by_id(self, resource_id): if not self._traffic_managers: self._populate_traffic_managers() return self._traffic_managers.get(resource_id) def _profile_name_to_id(self, name): return '/subscriptions/' + self._dns_client_subscription_id + \ '/resourceGroups/' + self._resource_group + \ '/providers/Microsoft.Network/trafficManagerProfiles/' + \ name def _get_tm_profile_by_name(self, name): profile_id = self._profile_name_to_id(name) return self._get_tm_profile_by_id(profile_id) def _get_tm_for_dynamic_record(self, record): name = _traffic_manager_suffix(record) return self._get_tm_profile_by_name(name) 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[:-1] self._populate_zones() 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): typ = _parse_azure_type(azrecord.type) if typ not in self.SUPPORTS: continue record = self._populate_record(zone, azrecord, lenient) zone.add_record(record, lenient=lenient) self.log.info('populate: found %s records, exists=%s', len(zone.records) - before, exists) return exists def _populate_record(self, zone, azrecord, lenient=False): record_name = azrecord.name if azrecord.name != '@' else '' typ = _parse_azure_type(azrecord.type) data_for = getattr(self, '_data_for_{}'.format(typ)) data = data_for(azrecord) data['type'] = typ data['ttl'] = azrecord.ttl return Record.new(zone, record_name, data, source=self, lenient=lenient) def _data_for_A(self, azrecord): return {'values': [ar.ipv4_address for ar in azrecord.a_records]} def _data_for_AAAA(self, azrecord): return {'values': [ar.ipv6_address for ar in azrecord.aaaa_records]} def _data_for_CAA(self, azrecord): return {'values': [{'flags': ar.flags, 'tag': ar.tag, 'value': ar.value} for ar in azrecord.caa_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 ''' if azrecord.cname_record is None and azrecord.target_resource.id: return self._data_for_dynamic(azrecord) return {'value': _check_endswith_dot(azrecord.cname_record.cname)} 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): ptrdname = azrecord.ptr_records[0].ptrdname return {'value': _check_endswith_dot(ptrdname)} 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': [escape_semicolon(reduce((lambda a, b: a + b), ar.value)) for ar in azrecord.txt_records]} def _data_for_dynamic(self, azrecord): default = set() pools = defaultdict(lambda: {'fallback': None, 'values': []}) rules = [] # top level geo profile geo_profile = self._get_tm_profile_by_id(azrecord.target_resource.id) for geo_ep in geo_profile.endpoints: rule = {} # resolve list of regions geo_map = list(geo_ep.geo_mapping) if geo_map != ['WORLD']: if 'GEO-ME' in geo_map: # Azure treats Middle East as a separate group, but # its part of Asia in octoDNS, so we need to remove GEO-ME # if GEO-AS is also in the list # Throw exception otherwise, it should not happen if the # profile was generated by octoDNS if 'GEO-AS' not in geo_map: msg = '_data_for_dynamic: Profile={}: '.format( geo_profile.name) msg += 'Middle East (GEO-ME) is not supported by ' + \ 'octoDNS. It needs to be either paired ' + \ 'with Asia (GEO-AS) or expanded into ' + \ 'individual list of countries.' raise AzureException(msg) geo_map.remove('GEO-ME') geos = rule.setdefault('geos', []) for code in geo_map: if code.startswith('GEO-'): geos.append(code[len('GEO-'):]) elif '-' in code: country, province = code.split('-', 1) country = GeoCodes.country_to_code(country) geos.append('{}-{}'.format(country, province)) else: geos.append(GeoCodes.country_to_code(code)) # second level priority profile pool = None rule_endpoints = geo_ep.target_resource.endpoints rule_endpoints = sorted(rule_endpoints, key=lambda e: e.priority) for rule_ep in rule_endpoints: pool_name = rule_ep.name # last/default pool if pool_name == '--default--': default.add(rule_ep.target) # this should be the last one, so let's break here break # set first priority endpoint as the rule's primary pool if 'pool' not in rule: rule['pool'] = pool_name if pool: # set current pool as fallback of the previous pool pool['fallback'] = pool_name pool = pools[pool_name] endpoints = [] # these should be leaf node entries with no further nesting if rule_ep.target_resource_id: # third (and last) level weighted RR profile endpoints = rule_ep.target_resource.endpoints else: # single-value pool endpoints = [rule_ep] for pool_ep in endpoints: val = pool_ep.target value_dict = { 'value': _check_endswith_dot(val), 'weight': pool_ep.weight or 1, } if value_dict not in pool['values']: pool['values'].append(value_dict) if 'pool' not in rule or not default: # this will happen if the priority profile does not have # enough endpoints msg = 'Expected at least 2 endpoints in {}, got {}'.format( geo_ep.target_resource.name, len(rule_endpoints) ) raise AzureException(msg) rules.append(rule) # Order and convert to a list default = sorted(default) data = { 'dynamic': { 'pools': pools, 'rules': rules, }, 'value': _check_endswith_dot(default[0]), } return data def _extra_changes(self, existing, desired, changes): changed = set() # Abort if there are non-CNAME dynamic records for change in changes: record = change.record changed.add(record) typ = record._type dynamic = getattr(record, 'dynamic', False) if dynamic and typ != 'CNAME': msg = '{}: Dynamic records in Azure must be of type CNAME' msg = msg.format(record.fqdn) raise AzureException(msg) log = self.log.info extra = [] for record in desired.records: if not getattr(record, 'dynamic', False): # Already changed, or not dynamic, no need to check it continue # let's walk through and show what will be changed even if # the record is already be in list of changes added = (record in changed) active = set() profiles = self._generate_traffic_managers(record) for profile in profiles: name = profile.name active.add(name) existing_profile = self._get_tm_profile_by_name(name) if not _profile_is_match(existing_profile, profile): log('_extra_changes: Profile name=%s will be synced', name) if not added: extra.append(Update(record, record)) added = True existing_profiles = self._find_traffic_managers(record) for name in existing_profiles - active: log('_extra_changes: Profile name=%s will be destroyed', name) if not added: extra.append(Update(record, record)) added = True return extra def _generate_tm_profile(self, name, routing, endpoints, record): # set appropriate endpoint types endpoint_type_prefix = 'Microsoft.Network/trafficManagerProfiles/' for ep in endpoints: if ep.target_resource_id: ep.type = endpoint_type_prefix + 'nestedEndpoints' elif ep.target: ep.type = endpoint_type_prefix + 'externalEndpoints' else: msg = ('_generate_tm_profile: Invalid endpoint {} ' + 'in profile {}, needs to have either target or ' + 'target_resource_id').format(ep.name, name) raise AzureException(msg) # build and return return Profile( id=self._profile_name_to_id(name), name=name, traffic_routing_method=routing, dns_config=DnsConfig( relative_name=name, ttl=record.ttl, ), monitor_config=_get_monitor(record), endpoints=endpoints, location='global', ) def _generate_traffic_managers(self, record): traffic_managers = [] pools = record.dynamic.pools tm_suffix = _traffic_manager_suffix(record) profile = self._generate_tm_profile geo_endpoints = [] for rule in record.dynamic.rules: pool_name = rule.data['pool'] rule_endpoints = [] priority = 1 while pool_name: # iterate until we reach end of fallback chain pool = pools[pool_name].data profile_name = 'pool-{}--{}'.format(pool_name, tm_suffix) if len(pool['values']) > 1: # create Weighted profile for multi-value pool endpoints = [] for val in pool['values']: target = val['value'] # strip trailing dot from CNAME value target = target[:-1] endpoints.append(Endpoint( name=target, target=target, weight=val.get('weight', 1), )) pool_profile = profile(profile_name, 'Weighted', endpoints, record) traffic_managers.append(pool_profile) # append pool to endpoint list of fallback rule profile rule_endpoints.append(Endpoint( name=pool_name, target_resource_id=pool_profile.id, priority=priority, )) else: # add single-value pool as an external endpoint target = pool['values'][0]['value'][:-1] rule_endpoints.append(Endpoint( name=pool_name, target=target, priority=priority, )) priority += 1 pool_name = pool.get('fallback') # append default profile to the end rule_endpoints.append(Endpoint( name='--default--', target=record.value[:-1], priority=priority, )) # create rule profile with fallback chain rule_profile_name = 'rule-{}--{}'.format(rule.data['pool'], tm_suffix) rule_profile = profile(rule_profile_name, 'Priority', rule_endpoints, record) traffic_managers.append(rule_profile) # append rule profile to top-level geo profile rule_geos = rule.data.get('geos', []) geos = [] if len(rule_geos) > 0: for geo in rule_geos: if '-' in geo: geos.append(geo.split('-', 1)[-1]) else: geos.append('GEO-{}'.format(geo)) if geo == 'AS': # Middle East is part of Asia in octoDNS, but # Azure treats it as a separate "group", so let's # add it in the list of geo mappings. We will drop # it when we later parse the list of regions. geos.append('GEO-ME') else: geos.append('WORLD') geo_endpoints.append(Endpoint( name='rule-{}'.format(rule.data['pool']), target_resource_id=rule_profile.id, geo_mapping=geos, )) geo_profile = profile(tm_suffix, 'Geographic', geo_endpoints, record) traffic_managers.append(geo_profile) return traffic_managers def _sync_traffic_managers(self, record): desired_profiles = self._generate_traffic_managers(record) seen = set() tm_sync = self._tm_client.profiles.create_or_update populate = self._populate_nested_profiles for desired in desired_profiles: name = desired.name if name in seen: continue existing = self._get_tm_profile_by_name(name) if not _profile_is_match(existing, desired): self.log.info( '_sync_traffic_managers: Syncing profile=%s', name) profile = tm_sync(self._resource_group, name, desired) self._traffic_managers[profile.id] = populate(profile) else: self.log.debug( '_sync_traffic_managers: Skipping profile=%s: up to date', name) seen.add(name) return seen def _find_traffic_managers(self, record): tm_suffix = _traffic_manager_suffix(record) profiles = set() for profile_id in self._traffic_managers: # match existing profiles with record's suffix name = profile_id.split('/')[-1] if name == tm_suffix or \ name.endswith('--{}'.format(tm_suffix)): profiles.add(name) return profiles def _traffic_managers_gc(self, record, active_profiles): existing_profiles = self._find_traffic_managers(record) # delete unused profiles for profile_name in existing_profiles - active_profiles: self.log.info('_traffic_managers_gc: Deleting profile=%s', profile_name) self._tm_client.profiles.delete(self._resource_group, profile_name) 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 ''' record = change.new dynamic = getattr(record, 'dynamic', False) if dynamic: self._sync_traffic_managers(record) profile = self._get_tm_for_dynamic_record(record) ar = _AzureRecord(self._resource_group, record, traffic_manager=profile) 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: {}'.format(record)) def _apply_Update(self, change): '''A record from change must be created. :param change: a change object :type change: octodns.record.Change :type return: void ''' existing = change.existing new = change.new existing_is_dynamic = getattr(existing, 'dynamic', False) new_is_dynamic = getattr(new, 'dynamic', False) update_record = True if new_is_dynamic: active = self._sync_traffic_managers(new) # only TTL is configured in record, everything else goes inside # traffic managers, so no need to update if TTL is unchanged # and existing record is already aliased to its traffic manager if existing.ttl == new.ttl and existing_is_dynamic: update_record = False if update_record: profile = self._get_tm_for_dynamic_record(new) ar = _AzureRecord(self._resource_group, new, traffic_manager=profile) update = self._dns_client.record_sets.create_or_update update(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) if new_is_dynamic: # let's cleanup unused traffic managers self._traffic_managers_gc(new, active) elif existing_is_dynamic: # cleanup traffic managers when a dynamic record gets # changed to a simple record self._traffic_managers_gc(existing, set()) self.log.debug('* Success Update: {}'.format(new)) def _apply_Delete(self, change): '''A record from change must be deleted. :param change: a change object :type change: octodns.record.Change :type return: void ''' record = change.record ar = _AzureRecord(self._resource_group, record, delete=True) delete = self._dns_client.record_sets.delete delete(self._resource_group, ar.zone_name, ar.relative_record_set_name, ar.record_type) if getattr(record, 'dynamic', False): self._traffic_managers_gc(record, set()) 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) ''' Force the operation order to be Delete() before all other operations. Helps avoid problems in updating - a CNAME record into an A record. - an A record into a CNAME record. ''' for change in changes: class_name = change.__class__.__name__ if class_name == 'Delete': self._apply_Delete(change) for change in changes: class_name = change.__class__.__name__ if class_name != 'Delete': getattr(self, '_apply_{}'.format(class_name))(change)