diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b68df8d..8b3cca6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: strategy: matrix: # Tested versions based on dates in https://devguide.python.org/devcycle/#end-of-life-branches, - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ['3.7', '3.8', '3.9'] steps: - uses: actions/checkout@master - name: Setup python diff --git a/CHANGELOG.md b/CHANGELOG.md index 829887c..a5497ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ https://github.com/octodns/octodns/issues/622 & https://github.com/octodns/octodns/pull/822 for more information. Providers that have been extracted in this release include: + * [AzureProvider](https://github.com/octodns/octodns-azure/) * [AkamaiProvider](https://github.com/octodns/octodns-edgedns/) * [CloudflareProvider](https://github.com/octodns/octodns-cloudflare/) * [ConstellixProvider](https://github.com/octodns/octodns-constellix/) @@ -16,6 +17,7 @@ * [EasyDnsProvider](https://github.com/octodns/octodns-easydns/) * [EtcHostsProvider](https://github.com/octodns/octodns-etchosts/) * [GcoreProvider](https://github.com/octodns/octodns-gcore/) + * [GandiProvider](https://github.com/octodns/octodns-gandi/) * [Ns1Provider](https://github.com/octodns/octodns-ns1/) * [PowerDnsProvider](https://github.com/octodns/octodns-powerdns/) * [Route53Provider](https://github.com/octodns/octodns-route53/) also diff --git a/README.md b/README.md index a469493..97ad018 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ The table below lists the providers octoDNS supports. We're currently in the pro | Provider | Module | Requirements | Record Support | Dynamic | Notes | |--|--|--|--|--|--| -| [AzureProvider](/octodns/provider/azuredns.py) | | azure-identity, azure-mgmt-dns, azure-mgmt-trafficmanager | A, AAAA, CAA, CNAME, MX, NS, PTR, SRV, TXT | Alpha (A, AAAA, CNAME) | | +| [AzureProvider](https://github.com/octodns/octodns-azure/) | [octodns_azure](https://github.com/octodns/octodns-azure/) | | | | | | [AkamaiProvider](https://github.com/octodns/octodns-edgedns/) | [octodns_edgedns](https://github.com/octodns/octodns-edgedns/) | | | | | | [CloudflareProvider](https://github.com/octodns/octodns-cloudflare/) | [octodns_cloudflare](https://github.com/octodns/octodns-cloudflare/) | | | | | | [ConstellixProvider](https://github.com/octodns/octodns-constellix/) | [octodns_constellix](https://github.com/octodns/octodns-constellix/) | | | | | @@ -203,7 +203,7 @@ The table below lists the providers octoDNS supports. We're currently in the pro | [EasyDnsProvider](https://github.com/octodns/octodns-easydns/) | [octodns_easydns](https://github.com/octodns/octodns-easydns/) | | | | | | [EtcHostsProvider](https://github.com/octodns/octodns-etchosts/) | [octodns_etchosts](https://github.com/octodns/octodns-etchosts/) | | | | | | [EnvVarSource](/octodns/source/envvar.py) | | | TXT | No | read-only environment variable injection | -| [GandiProvider](/octodns/provider/gandi.py) | | | A, AAAA, ALIAS, CAA, CNAME, DNAME, MX, NS, PTR, SPF, SRV, SSHFP, TXT | No | | +| [GandiProvider](https://github.com/octodns/octodns-gandi/) | [octodns_gandi](https://github.com/octodns/octodns-gandi/) | | | | | | [GCoreProvider](https://github.com/octodns/octodns-gcore/) | [octodns_gcore](https://github.com/octodns/octodns-gcore/) | | | | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [HetznerProvider](/octodns/provider/hetzner.py) | | | A, AAAA, CAA, CNAME, MX, NS, SRV, TXT | No | | diff --git a/octodns/provider/azuredns.py b/octodns/provider/azuredns.py index b057f43..d785adf 100644 --- a/octodns/provider/azuredns.py +++ b/octodns/provider/azuredns.py @@ -5,1500 +5,17 @@ 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 . import ProviderException -from .base import BaseProvider - - -class AzureException(ProviderException): - 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 = f'{self.record_type}_records'.lower() - if record._type == 'CNAME': - key_name = key_name[:-1] - azure_class = self.TYPE_MAP[self.record_type] - - params_for = getattr(self, f'_params_for_{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): - if self._record.dynamic and self.traffic_manager: - return {'target_resource': self.traffic_manager} - - 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): - if self._record.dynamic and self.traffic_manager: - return {'target_resource': self.traffic_manager} - - 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(f'{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 _root_traffic_manager_name(record): - # ATM names can only have letters, numbers and hyphens - # replace dots with double hyphens to ensure unique mapping, - # hoping that real life FQDNs won't have double hyphens - name = record.fqdn[:-1].replace('.', '--') - if record._type != 'CNAME': - name += f'-{record._type}' - return name - - -def _rule_traffic_manager_name(pool, record): - prefix = _root_traffic_manager_name(record) - return f'{prefix}-rule-{pool}' - - -def _pool_traffic_manager_name(pool, record): - prefix = _root_traffic_manager_name(record) - return f'{prefix}-pool-{pool}' - - -def _healthcheck_tolerated_number_of_failures(record): - return record._octodns.get('azuredns', {}) \ - .get('healthcheck', {}) \ - .get('tolerated_number_of_failures') - - -def _healthcheck_interval_in_seconds(record): - return record._octodns.get('azuredns', {}) \ - .get('healthcheck', {}) \ - .get('interval_in_seconds') - - -def _healthcheck_timeout_in_seconds(record): - return record._octodns.get('azuredns', {}) \ - .get('healthcheck', {}) \ - .get('timeout_in_seconds') - - -def _get_monitor(record): - monitor = MonitorConfig( - protocol=record.healthcheck_protocol, - port=record.healthcheck_port, - path=record.healthcheck_path, - interval_in_seconds=_healthcheck_interval_in_seconds(record), - timeout_in_seconds=_healthcheck_timeout_in_seconds(record), - tolerated_number_of_failures= - _healthcheck_tolerated_number_of_failures(record), - ) - host = record.healthcheck_host() - if host: - monitor.custom_headers = [MonitorConfigCustomHeadersItem( - name='Host', value=host - )] - return monitor - - -def _check_valid_dynamic(record): - typ = record._type - if typ in ['A', 'AAAA']: - defaults = set(record.values) - if len(defaults) > 1: - pools = record.dynamic.pools - vals = set( - v['value'] - for _, pool in pools.items() - for v in pool._data()['values'] - ) - if defaults != vals: - # we don't yet support multi-value defaults, specifying all - # pool values allows for Traffic Manager profile optimization - raise AzureException(f'{record.fqdn} {record._type}: Values ' - 'of A/AAAA dynamic records must either ' - 'have a single value or contain all ' - 'values from all pools') - elif typ != 'CNAME': - # dynamic records of unsupported type - raise AzureException(f'{record.fqdn}: Dynamic records in Azure must ' - 'be of type A/AAAA/CNAME') - - -def _profile_is_match(have, desired): - if have is None or desired is None: - return False - - log = logging.getLogger('azuredns._profile_is_match').debug - - def false(have, desired, name=None): - prefix = f'profile={name}' if name else '' - attr = have.__class__.__name__ - log('%s have.%s = %s', prefix, attr, have) - log('%s desired.%s = %s', prefix, attr, desired) - return False - - # compare basic attributes - if have.name != desired.name or \ - have.traffic_routing_method != desired.traffic_routing_method or \ - len(have.endpoints) != len(desired.endpoints): - return false(have, desired) - - # compare dns config - dns_have = have.dns_config - dns_desired = desired.dns_config - if dns_have.ttl != dns_desired.ttl or \ - dns_have.relative_name is None or \ - dns_desired.relative_name is None or \ - dns_have.relative_name != dns_desired.relative_name: - return false(dns_have, dns_desired, have.name) - - # 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.tolerated_number_of_failures != \ - monitor_desired.tolerated_number_of_failures or \ - monitor_have.interval_in_seconds != \ - monitor_desired.interval_in_seconds or \ - monitor_have.timeout_in_seconds != \ - monitor_desired.timeout_in_seconds or \ - monitor_have.custom_headers != monitor_desired.custom_headers: - return false(monitor_have, monitor_desired, have.name) - - # 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: - have_status = have_endpoint.endpoint_status or 'Enabled' - desired_status = desired_endpoint.endpoint_status or 'Enabled' - - # compare basic attributes - if have_endpoint.name != desired_endpoint.name or \ - have_endpoint.type != desired_endpoint.type or \ - have_status != desired_status: - return false(have_endpoint, desired_endpoint, have.name) - - # 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(have_endpoint, desired_endpoint, have.name) - - # compare priorities - if method == 'Priority' and \ - have_endpoint.priority != desired_endpoint.priority: - return false(have_endpoint, desired_endpoint, have.name) - - # compare weights - if method == 'Weighted' and \ - have_endpoint.weight != desired_endpoint.weight: - return false(have_endpoint, desired_endpoint, have.name) - - # compare targets - target_type = have_endpoint.type.split('/')[-1] - if target_type == 'externalEndpoints': - if have_endpoint.target != desired_endpoint.target: - return false(have_endpoint, desired_endpoint, have.name) - elif target_type == 'nestedEndpoints': - if have_endpoint.target_resource_id != \ - desired_endpoint.target_resource_id: - return false(have_endpoint, desired_endpoint, have.name) - 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. - - Please read https://github.com/octodns/octodns/pull/706 for an overview - of how dynamic records are designed and caveats of using them. - ''' - SUPPORTS_GEO = False - SUPPORTS_DYNAMIC = True - SUPPORTS_POOL_VALUE_STATUS = True - SUPPORTS_MULTIVALUE_PTR = 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(f'AzureProvider[{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 = _root_traffic_manager_name(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, f'_data_for_{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): - if azrecord.a_records is None: - if azrecord.target_resource.id: - return self._data_for_dynamic(azrecord) - - # dynamic record alias is broken, return dummy value and apply - # will likely overwrite/fix it - self.log.warn('_data_for_A: Missing Traffic Manager alias for ' - 'dynamic record %s', azrecord.fqdn) - return {'values': []} - - return {'values': [ar.ipv4_address for ar in azrecord.a_records]} - - def _data_for_AAAA(self, azrecord): - if azrecord.aaaa_records is None: - if azrecord.target_resource.id: - return self._data_for_dynamic(azrecord) - - # dynamic record alias is broken, return dummy value and apply - # will likely overwrite/fix it - self.log.warn('_data_for_AAAA: Missing Traffic Manager alias for ' - 'dynamic record %s', azrecord.fqdn) - return {'values': []} - - 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: - if azrecord.target_resource.id: - return self._data_for_dynamic(azrecord) - - # dynamic record alias is broken, return dummy value and apply - # will likely overwrite/fix it - self.log.warn('_data_for_CNAME: Missing Traffic Manager alias for ' - 'dynamic record %s', azrecord.fqdn) - return {'value': None} - - 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): - vals = [ar.ptrdname for ar in azrecord.ptr_records] - return {'values': [_check_endswith_dot(val) for val in vals]} - - 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 _get_geo_endpoints(self, root_profile): - if root_profile.traffic_routing_method != 'Geographic': - # This record does not use geo fencing, so we skip the Geographic - # profile hop; let's pretend to be a geo-profile's only endpoint - geo_ep = Endpoint( - name=root_profile.endpoints[0].name.split('--', 1)[0], - target_resource_id=root_profile.id - ) - geo_ep.target_resource = root_profile - return [geo_ep] - - return root_profile.endpoints - - def _get_rule_endpoints(self, geo_ep): - if geo_ep.target_resource_id and \ - geo_ep.target_resource.traffic_routing_method == 'Priority': - return sorted( - geo_ep.target_resource.endpoints, key=lambda e: e.priority) - else: - # this geo directly points to a pool containing the default - # so we skip the Priority profile hop and directly use an - # external endpoint or Weighted profile - # let's pretend to be a Priority profile's only endpoint - return [geo_ep] - - def _get_pool_endpoints(self, rule_ep): - if rule_ep.target_resource_id: - # third (and last) level weighted RR profile - return rule_ep.target_resource.endpoints - else: - # single-value pool, so we skip the Weighted profile hop and - # directly use an external endpoint; let's pretend to be a - # Weighted profile's only endpoint - return [rule_ep] - - def _populate_geos(self, geo_map, name, fqdn): - 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, which should not happen - # if the profile was generated by octoDNS. - if 'GEO-AS' not in geo_map: - msg = f'Profile={name} for record {fqdn}: 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 = [] - for code in geo_map: - if code.startswith('GEO-'): - # continent - if code == 'GEO-AP': - # Azure uses Australia/Pacific (AP) instead of Oceania - # https://docs.microsoft.com/en-us/azure/traffic-manager/ - # traffic-manager-geographic-regions - geos.append('OC') - else: - geos.append(code[len('GEO-'):]) - elif '-' in code: - # state - country, province = code.split('-', 1) - country = GeoCodes.country_to_code(country) - geos.append(f'{country}-{province}') - elif code == 'WORLD': - geos.append(code) - else: - # country - geos.append(GeoCodes.country_to_code(code)) - - return geos - - def _populate_pool_values(self, rule_ep, typ, defaults): - values = [] - for pool_ep in self._get_pool_endpoints(rule_ep): - val = pool_ep.target - if typ == 'CNAME': - val = _check_endswith_dot(val) - - ep_name = pool_ep.name - if ep_name.endswith('--default--'): - defaults.add(val) - ep_name = ep_name[:-len('--default--')] - - status = 'obey' - if pool_ep.endpoint_status == 'Disabled': - status = 'down' - - values.append({ - 'value': val, - 'weight': pool_ep.weight or 1, - 'status': status, - }) - - return values - - def _populate_pools(self, geo_ep, typ, defaults, pools): - rule_endpoints = self._get_rule_endpoints(geo_ep) - rule_pool = None - pool = None - for rule_ep in rule_endpoints: - pool_name = rule_ep.name - - # last/default pool - if pool_name.endswith('--default--'): - defaults.add(rule_ep.target) - if pool_name == '--default--': - # this should be the last one, so let's break here - break - # last pool is a single value pool and its value is same as - # record's default value - pool_name = pool_name[:-len('--default--')] - - # set first priority endpoint as the rule's primary pool - if rule_pool is None: - rule_pool = pool_name - - if pool: - # set current pool as fallback of the previous pool - pool['fallback'] = pool_name - - if pool_name in pools: - # we've already populated this and subsequent pools - break - - # populate the pool from Weighted profile - # these should be leaf node entries with no further nesting - pool = pools[pool_name] - pool['values'] = self._populate_pool_values(rule_ep, typ, defaults) - - return rule_pool - - def _data_for_dynamic(self, azrecord): - typ = _parse_azure_type(azrecord.type) - defaults = set() - pools = defaultdict(lambda: {'fallback': None, 'values': []}) - rules = [] - - # top level profile - root_profile = self._get_tm_profile_by_id(azrecord.target_resource.id) - - # construct rules and, in turn, pools - for geo_ep in self._get_geo_endpoints(root_profile): - rule = {} - - # resolve list of regions - geo_map = list(geo_ep.geo_mapping or []) - if geo_map and geo_map != ['WORLD']: - rule['geos'] = self._populate_geos( - geo_map, root_profile.name, azrecord.fqdn) - - # build pool fallback chain from second level priority profile - rule['pool'] = self._populate_pools(geo_ep, typ, defaults, pools) - - rules.append(rule) - - # add separate rule for re-used world pool - for rule in list(rules): - geos = rule.get('geos', []) - if len(geos) > 1 and 'WORLD' in geos: - geos.remove('WORLD') - rules.append({'pool': rule['pool']}) - - # Order and convert to a list - defaults = sorted(defaults) - - data = { - 'dynamic': { - 'pools': pools, - 'rules': rules, - }, - } - - if typ == 'CNAME': - data['value'] = _check_endswith_dot(defaults[0]) - else: - data['values'] = defaults - - return data - - def _process_desired_zone(self, desired): - # check for status=up values - for record in desired.records: - if not getattr(record, 'dynamic', False): - continue - - up_pools = [] - for name, pool in record.dynamic.pools.items(): - for value in pool.data['values']: - if value['status'] == 'up': - # Azure only supports obey and down, not up - up_pools.append(name) - break - if not up_pools: - continue - - up_pools = ','.join(up_pools) - msg = f'status=up is not supported for pools {up_pools} in ' \ - f'{record.fqdn}' - fallback = 'will ignore it and respect the healthcheck' - self.supports_warn_or_except(msg, fallback) - - record = record.copy() - for pool in record.dynamic.pools.values(): - for value in pool.data['values']: - if value['status'] == 'up': - value['status'] = 'obey' - desired.add_record(record, replace=True) - - return super()._process_desired_zone(desired) - - def _extra_changes(self, existing, desired, changes): - changed = set(c.record for c in changes) - - log = self.log.info - seen_profiles = {} - extra = [] - for record in desired.records: - if not getattr(record, 'dynamic', False): - # Already changed, or not dynamic, no need to check it - continue - - # Abort if there are unsupported dynamic record configurations - _check_valid_dynamic(record) - - # let's walk through and show what will be changed even if - # the record is already in list of changes - added = (record in changed) - - active = set() - profiles = self._generate_traffic_managers(record) - - for profile in profiles: - name = profile.name - - endpoints = set() - for ep in profile.endpoints: - if not ep.target: - continue - if ep.target in endpoints: - raise AzureException(f'{name} contains duplicate ' - f'endpoint {ep.target}') - endpoints.add(ep.target) - - if name in seen_profiles: - # exit if a possible collision is detected, even though - # we've tried to ensure unique mapping - raise AzureException('Collision in Traffic Manager names ' - f'detected: {seen_profiles[name]} ' - f'and {record.fqdn} both want to ' - f'use {name}') - else: - seen_profiles[name] = record.fqdn - - 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, routing, endpoints, record, label=None): - # figure out profile name and Traffic Manager FQDN - name = _root_traffic_manager_name(record) - if routing == 'Weighted' and label: - name = _pool_traffic_manager_name(label, record) - elif routing == 'Priority' and label: - name = _rule_traffic_manager_name(label, 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: - raise AzureException(f'Invalid endpoint {ep.name} in profile ' - f'{name}, needs to have either target ' - 'or target_resource_id') - - # build and return - return Profile( - id=self._profile_name_to_id(name), - name=name, - traffic_routing_method=routing, - dns_config=DnsConfig( - relative_name=name.lower(), - ttl=record.ttl, - ), - monitor_config=_get_monitor(record), - endpoints=endpoints, - location='global', - ) - - def _convert_tm_to_root(self, profile, record): - profile.name = _root_traffic_manager_name(record) - profile.id = self._profile_name_to_id(profile.name) - profile.dns_config.relative_name = profile.name.lower() - - return profile - - def _make_azure_geos(self, rule_geos): - geos = [] - for geo in rule_geos: - if '-' in geo: - # country/state - geos.append(geo.split('-', 1)[-1]) - else: - # continent - 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') - elif geo == 'OC': - # Azure uses Australia/Pacific (AP) instead of Oceania - geo = 'AP' - - geos.append(f'GEO-{geo}') - - return geos - - def _make_pool_profile(self, pool, record, defaults): - pool_name = pool._id - default_seen = False - - endpoints = [] - for val in pool.data['values']: - target = val['value'] - # strip trailing dot from CNAME value - if record._type == 'CNAME': - target = target[:-1] - ep_name = f'{pool_name}--{target}' - # Endpoint names cannot have colons, drop them from IPv6 addresses - ep_name = ep_name.replace(':', '-') - if target in defaults: - # mark default - ep_name += '--default--' - default_seen = True - ep_status = 'Disabled' if val['status'] == 'down' else \ - 'Enabled' - endpoints.append(Endpoint( - name=ep_name, - target=target, - weight=val.get('weight', 1), - endpoint_status=ep_status, - )) - - pool_profile = self._generate_tm_profile( - 'Weighted', endpoints, record, pool_name) - - return pool_profile, default_seen - - def _make_pool(self, pool, priority, pool_profiles, record, defaults, - traffic_managers): - pool_name = pool._id - pool_values = pool.data['values'] - default_seen = False - - if len(pool_values) > 1: - # create Weighted profile for multi-value pool - pool_profile = pool_profiles.get(pool_name) - # TODO: what if a cached pool_profile had seen the default - if pool_profile is None: - pool_profile, default_seen = self._make_pool_profile( - pool, record, defaults) - traffic_managers.append(pool_profile) - pool_profiles[pool_name] = pool_profile - - # append pool to endpoint list of fallback rule profile - return Endpoint( - name=pool_name, - target_resource_id=pool_profile.id, - priority=priority, - ), default_seen - else: - # Skip Weighted profile hop for single-value pool; append its - # value as an external endpoint to fallback rule profile - value = pool_values[0] - target = value['value'] - if record._type == 'CNAME': - target = target[:-1] - ep_name = pool_name - if target in defaults: - # mark default - ep_name += '--default--' - default_seen = True - ep_status = 'Disabled' if value['status'] == 'down' else \ - 'Enabled' - return Endpoint( - name=ep_name, - target=target, - priority=priority, - endpoint_status=ep_status, - ), default_seen - - def _make_rule_profile(self, rule_endpoints, rule_name, record, geos, - traffic_managers): - if len(rule_endpoints) > 1: - # create rule profile with fallback chain - rule_profile = self._generate_tm_profile( - 'Priority', rule_endpoints, record, rule_name) - traffic_managers.append(rule_profile) - - # append rule profile to top-level geo profile - return Endpoint( - name=rule_name, - target_resource_id=rule_profile.id, - geo_mapping=geos, - ) - else: - # Priority profile has only one endpoint; skip the hop and append - # its only endpoint to the top-level profile - rule_ep = rule_endpoints[0] - if rule_ep.target_resource_id: - # point directly to the Weighted pool profile - return Endpoint( - name=rule_ep.name, - target_resource_id=rule_ep.target_resource_id, - geo_mapping=geos, - ) - else: - # just add the value of single-value pool - return Endpoint( - name=rule_ep.name, - target=rule_ep.target, - geo_mapping=geos, - ) - - def _make_rule(self, pool_name, pool_profiles, record, geos, - traffic_managers): - endpoints = [] - rule_name = pool_name - - if record._type == 'CNAME': - defaults = [record.value[:-1]] - else: - defaults = record.values - - priority = 1 - default_seen = False - - while pool_name: - # iterate until we reach end of fallback chain - pool = record.dynamic.pools[pool_name] - - rule_ep, saw_default = self._make_pool( - pool, priority, pool_profiles, record, defaults, - traffic_managers - ) - endpoints.append(rule_ep) - if saw_default: - default_seen = True - - priority += 1 - pool_name = pool.data.get('fallback') - - # append default endpoint unless it is already included in last pool - # of rule profile - if not default_seen: - endpoints.append(Endpoint( - name='--default--', - target=defaults[0], - priority=priority, - )) - - return self._make_rule_profile( - endpoints, rule_name, record, geos, traffic_managers - ) - - def _make_geo_rules(self, record): - rules = record.dynamic.rules - - # a pool can be re-used only with a world pool, record the pool - # to later consolidate it with a geo pool if one exists since we - # can't have multiple endpoints with the same target in ATM - world_pool = None - for rule in rules: - if not rule.data.get('geos', []): - world_pool = rule.data['pool'] - - traffic_managers = [] - geo_endpoints = [] - pool_profiles = {} - world_seen = False - - for rule in rules: - rule = rule.data - pool_name = rule['pool'] - rule_geos = rule.get('geos', []) - - if pool_name == world_pool and world_seen: - # this world pool is already mentioned in another geo rule - continue - - # Prepare the list of Traffic manager geos - geos = self._make_azure_geos(rule_geos) - if not geos or pool_name == world_pool: - geos.append('WORLD') - world_seen = True - - geo_endpoints.append(self._make_rule( - pool_name, pool_profiles, record, geos, traffic_managers - )) - - return geo_endpoints, traffic_managers - - def _generate_traffic_managers(self, record): - geo_endpoints, traffic_managers = self._make_geo_rules(record) - - if len(geo_endpoints) == 1 and \ - geo_endpoints[0].geo_mapping == ['WORLD'] and \ - geo_endpoints[0].target_resource_id: - # Single WORLD rule does not require a Geographic profile, use the - # target profile (which is at the end) as the root profile - self._convert_tm_to_root(traffic_managers[-1], record) - else: - geo_profile = self._generate_tm_profile( - 'Geographic', geo_endpoints, record) - traffic_managers.append(geo_profile) - - return traffic_managers - - def _sync_traffic_managers(self, desired_profiles): - 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_prefix = _root_traffic_manager_name(record) - - profiles = set() - for profile_id in self._traffic_managers: - # match existing profiles with record's prefix - name = profile_id.split('/')[-1] - if name == tm_prefix or \ - name.startswith(f'{tm_prefix}-pool-') or \ - name.startswith(f'{tm_prefix}-rule-'): - 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) - root_profile = None - endpoints = [] - if dynamic: - profiles = self._generate_traffic_managers(record) - root_profile = profiles[-1] - if record._type in ['A', 'AAAA'] and len(profiles) > 1: - # A/AAAA records cannot be aliased to Traffic Managers that - # contain other nested Traffic Managers. To work around this - # limitation, we remove nesting before adding the record, and - # then add the nested endpoints later. - endpoints = root_profile.endpoints - root_profile.endpoints = [] - self._sync_traffic_managers(profiles) - - ar = _AzureRecord(self._resource_group, record, - traffic_manager=root_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) - - if endpoints: - # add nested endpoints for A/AAAA dynamic record limitation after - # record creation - root_profile.endpoints = endpoints - self._sync_traffic_managers([root_profile]) - - self.log.debug('* Success Create: %s', 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: - endpoints = [] - profiles = self._generate_traffic_managers(new) - root_profile = profiles[-1] - - if new._type in ['A', 'AAAA']: - if existing_is_dynamic: - # update to the record is not needed - update_record = False - elif len(profiles) > 1: - # record needs to aliased; remove nested endpoints, we - # will add them at the end - endpoints = root_profile.endpoints - root_profile.endpoints = [] - elif existing.ttl == new.ttl and existing_is_dynamic: - # CNAME dynamic records only have TTL in them, everything else - # goes inside the aliased traffic managers; skip update if TTL - # is unchanged and existing record is already aliased to its - # traffic manager - update_record = False - - active = self._sync_traffic_managers(profiles) - - 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: - # add any pending nested endpoints - if endpoints: - root_profile.endpoints = endpoints - self._sync_traffic_managers([root_profile]) - # 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: %s', 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: %s', record) - - 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, f'_apply_{class_name}')(change) +from logging import getLogger + +logger = getLogger('Azure') +try: + logger.warn('octodns_azure shimmed. Update your provider class to ' + 'octodns_azure.AzureProvider. ' + 'Shim will be removed in 1.0') + from octodns_azure import AzureProvider + AzureProvider # pragma: no cover +except ModuleNotFoundError: + logger.exception('AzureProvider has been moved into a seperate module, ' + 'octodns_azure is now required. Provider class should ' + 'be updated to octodns_azure.AzureProvider') + raise diff --git a/octodns/provider/gandi.py b/octodns/provider/gandi.py index b1c4082..70523e8 100644 --- a/octodns/provider/gandi.py +++ b/octodns/provider/gandi.py @@ -5,373 +5,19 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from collections import defaultdict -from requests import Session -import logging - -from ..record import Record -from . import ProviderException -from .base import BaseProvider - - -class GandiClientException(ProviderException): - pass - - -class GandiClientBadRequest(GandiClientException): - - def __init__(self, r): - super(GandiClientBadRequest, self).__init__(r.text) - - -class GandiClientUnauthorized(GandiClientException): - - def __init__(self, r): - super(GandiClientUnauthorized, self).__init__(r.text) - - -class GandiClientForbidden(GandiClientException): - - def __init__(self, r): - super(GandiClientForbidden, self).__init__(r.text) - - -class GandiClientNotFound(GandiClientException): - - def __init__(self, r): - super(GandiClientNotFound, self).__init__(r.text) - - -class GandiClientUnknownDomainName(GandiClientException): - - def __init__(self, msg): - super(GandiClientUnknownDomainName, self).__init__(msg) - - -class GandiClient(object): - - def __init__(self, token): - session = Session() - session.headers.update({'Authorization': f'Apikey {token}'}) - self._session = session - self.endpoint = 'https://api.gandi.net/v5' - - def _request(self, method, path, params={}, data=None): - url = f'{self.endpoint}{path}' - r = self._session.request(method, url, params=params, json=data) - if r.status_code == 400: - raise GandiClientBadRequest(r) - if r.status_code == 401: - raise GandiClientUnauthorized(r) - elif r.status_code == 403: - raise GandiClientForbidden(r) - elif r.status_code == 404: - raise GandiClientNotFound(r) - r.raise_for_status() - return r - - def zone(self, zone_name): - return self._request('GET', f'/livedns/domains/{zone_name}').json() - - def zone_create(self, zone_name): - return self._request('POST', '/livedns/domains', data={ - 'fqdn': zone_name, - 'zone': {} - }).json() - - def zone_records(self, zone_name): - records = self._request('GET', - f'/livedns/domains/{zone_name}/records').json() - - for record in records: - if record['rrset_name'] == '@': - record['rrset_name'] = '' - - # Change relative targets to absolute ones. - if record['rrset_type'] in ['ALIAS', 'CNAME', 'DNAME', 'MX', - 'NS', 'SRV']: - for i, value in enumerate(record['rrset_values']): - if not value.endswith('.'): - record['rrset_values'][i] = f'{value}.{zone_name}.' - - return records - - def record_create(self, zone_name, data): - self._request('POST', f'/livedns/domains/{zone_name}/records', - data=data) - - def record_delete(self, zone_name, record_name, record_type): - self._request('DELETE', f'/livedns/domains/{zone_name}/records/' - f'{record_name}/{record_type}') - - -class GandiProvider(BaseProvider): - ''' - Gandi provider using API v5. - - gandi: - class: octodns.provider.gandi.GandiProvider - # Your API key (required) - token: XXXXXXXXXXXX - ''' - - SUPPORTS_GEO = False - SUPPORTS_DYNAMIC = False - SUPPORTS = set((['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', - 'MX', 'NS', 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT'])) - - def __init__(self, id, token, *args, **kwargs): - self.log = logging.getLogger(f'GandiProvider[{id}]') - self.log.debug('__init__: id=%s, token=***', id) - super(GandiProvider, self).__init__(id, *args, **kwargs) - self._client = GandiClient(token) - - self._zone_records = {} - - def _data_for_multiple(self, _type, records): - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': [v.replace(';', '\\;') for v in - records[0]['rrset_values']] if _type == 'TXT' else - records[0]['rrset_values'] - } - - _data_for_A = _data_for_multiple - _data_for_AAAA = _data_for_multiple - _data_for_TXT = _data_for_multiple - _data_for_SPF = _data_for_multiple - _data_for_NS = _data_for_multiple - - def _data_for_CAA(self, _type, records): - values = [] - for record in records[0]['rrset_values']: - flags, tag, value = record.split(' ') - values.append({ - 'flags': flags, - 'tag': tag, - # Remove quotes around value. - 'value': value[1:-1], - }) - - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': values - } - - def _data_for_single(self, _type, records): - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'value': records[0]['rrset_values'][0] - } - - _data_for_ALIAS = _data_for_single - _data_for_CNAME = _data_for_single - _data_for_DNAME = _data_for_single - _data_for_PTR = _data_for_single - - def _data_for_MX(self, _type, records): - values = [] - for record in records[0]['rrset_values']: - priority, server = record.split(' ') - values.append({ - 'preference': priority, - 'exchange': server - }) - - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': values - } - - def _data_for_SRV(self, _type, records): - values = [] - for record in records[0]['rrset_values']: - priority, weight, port, target = record.split(' ', 3) - values.append({ - 'priority': priority, - 'weight': weight, - 'port': port, - 'target': target - }) - - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': values - } - - def _data_for_SSHFP(self, _type, records): - values = [] - for record in records[0]['rrset_values']: - algorithm, fingerprint_type, fingerprint = record.split(' ', 2) - values.append({ - 'algorithm': algorithm, - 'fingerprint': fingerprint, - 'fingerprint_type': fingerprint_type - }) - - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': values - } - - def zone_records(self, zone): - if zone.name not in self._zone_records: - try: - self._zone_records[zone.name] = \ - self._client.zone_records(zone.name[:-1]) - except GandiClientNotFound: - return [] - - return self._zone_records[zone.name] - - def populate(self, zone, target=False, lenient=False): - self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, - target, lenient) - - values = defaultdict(lambda: defaultdict(list)) - for record in self.zone_records(zone): - _type = record['rrset_type'] - if _type not in self.SUPPORTS: - continue - values[record['rrset_name']][record['rrset_type']].append(record) - - before = len(zone.records) - for name, types in values.items(): - for _type, records in types.items(): - data_for = getattr(self, f'_data_for_{_type}') - record = Record.new(zone, name, data_for(_type, records), - source=self, lenient=lenient) - zone.add_record(record, lenient=lenient) - - exists = zone.name in self._zone_records - self.log.info('populate: found %s records, exists=%s', - len(zone.records) - before, exists) - return exists - - def _record_name(self, name): - return name if name else '@' - - def _params_for_multiple(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [v.replace('\\;', ';') for v in - record.values] if record._type == 'TXT' - else record.values - } - - _params_for_A = _params_for_multiple - _params_for_AAAA = _params_for_multiple - _params_for_NS = _params_for_multiple - _params_for_TXT = _params_for_multiple - _params_for_SPF = _params_for_multiple - - def _params_for_CAA(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [f'{v.flags} {v.tag} "{v.value}"' - for v in record.values] - } - - def _params_for_single(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [record.value] - } - - _params_for_ALIAS = _params_for_single - _params_for_CNAME = _params_for_single - _params_for_DNAME = _params_for_single - _params_for_PTR = _params_for_single - - def _params_for_MX(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [f'{v.preference} {v.exchange}' - for v in record.values] - } - - def _params_for_SRV(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [f'{v.priority} {v.weight} {v.port} {v.target}' - for v in record.values] - } - - def _params_for_SSHFP(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [f'{v.algorithm} {v.fingerprint_type} ' - f'{v.fingerprint}' for v in record.values] - } - - def _apply_create(self, change): - new = change.new - data = getattr(self, f'_params_for_{new._type}')(new) - self._client.record_create(new.zone.name[:-1], data) - - def _apply_update(self, change): - self._apply_delete(change) - self._apply_create(change) - - def _apply_delete(self, change): - existing = change.existing - zone = existing.zone - self._client.record_delete(zone.name[:-1], - self._record_name(existing.name), - existing._type) - - def _apply(self, plan): - desired = plan.desired - changes = plan.changes - zone = desired.name[:-1] - self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, - len(changes)) - - try: - self._client.zone(zone) - except GandiClientNotFound: - self.log.info('_apply: no existing zone, trying to create it') - try: - self._client.zone_create(zone) - self.log.info('_apply: zone has been successfully created') - except GandiClientNotFound: - # We suppress existing exception before raising - # GandiClientUnknownDomainName. - e = GandiClientUnknownDomainName('This domain is not ' - 'registered at Gandi. ' - 'Please register or ' - 'transfer it here ' - 'to be able to manage its ' - 'DNS zone.') - e.__cause__ = None - raise e - - # Force records deletion to be done before creation in order to avoid - # "CNAME record must be the only record" error when an existing CNAME - # record is replaced by an A/AAAA record. - changes.reverse() - - for change in changes: - class_name = change.__class__.__name__ - getattr(self, f'_apply_{class_name.lower()}')(change) - - # Clear out the cache if any - self._zone_records.pop(desired.name, None) +from logging import getLogger + +logger = getLogger('Gandi') +try: + logger.warn('octodns_gandi shimmed. Update your provider class to ' + 'octodns_gandi.GandiProvider. ' + 'Shim will be removed in 1.0') + from octodns_gandi import GandiProvider + GandiProvider # pragma: no cover +except ModuleNotFoundError: + logger.exception('GandiProvider has been moved into a seperate module, ' + 'octodns_gandi is now required. Provider class should ' + 'be updated to octodns_gandi.GandiProvider. See ' + 'https://github.com/octodns/octodns/README.md#updating-' + 'to-use-extracted-providers for more information.') + raise diff --git a/requirements.txt b/requirements.txt index d4e6353..d473bd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,15 @@ PyYaml==5.4 -azure-common==1.1.27 -azure-identity==1.5.0 -azure-mgmt-dns==8.0.0 -azure-mgmt-trafficmanager==0.51.0 dnspython==1.16.0 docutils==0.16 fqdn==1.5.0 google-cloud-core==1.4.1 google-cloud-dns==0.32.0 jmespath==0.10.0 -msrestazure==0.6.4 natsort==6.2.1 ovh==0.5.0 pycountry-convert==0.7.2 -pycountry==20.7.3 +pycountry==22.1.10 python-dateutil==2.8.1 requests==2.25.1 -setuptools==44.1.1 +setuptools==60.5.0 python-transip==0.5.0 diff --git a/tests/fixtures/gandi-no-changes.json b/tests/fixtures/gandi-no-changes.json deleted file mode 100644 index a67dc93..0000000 --- a/tests/fixtures/gandi-no-changes.json +++ /dev/null @@ -1,154 +0,0 @@ -[ - { - "rrset_type": "A", - "rrset_ttl": 300, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", - "rrset_values": [ - "1.2.3.4", - "1.2.3.5" - ] - }, - { - "rrset_type": "CAA", - "rrset_ttl": 3600, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/CAA", - "rrset_values": [ - "0 issue \"ca.unit.tests\"" - ] - }, - { - "rrset_type": "SSHFP", - "rrset_ttl": 3600, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/SSHFP", - "rrset_values": [ - "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49", - "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73" - ] - }, - { - "rrset_type": "AAAA", - "rrset_ttl": 600, - "rrset_name": "aaaa", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/aaaa/AAAA", - "rrset_values": [ - "2601:644:500:e210:62f8:1dff:feb8:947a" - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 300, - "rrset_name": "cname", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/cname/CNAME", - "rrset_values": [ - "unit.tests." - ] - }, - { - "rrset_type": "DNAME", - "rrset_ttl": 300, - "rrset_name": "dname", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/dname/DNAME", - "rrset_values": [ - "unit.tests." - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 3600, - "rrset_name": "excluded", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/excluded/CNAME", - "rrset_values": [ - "unit.tests." - ] - }, - { - "rrset_type": "MX", - "rrset_ttl": 300, - "rrset_name": "mx", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/mx/MX", - "rrset_values": [ - "10 smtp-4.unit.tests.", - "20 smtp-2.unit.tests.", - "30 smtp-3.unit.tests.", - "40 smtp-1.unit.tests." - ] - }, - { - "rrset_type": "PTR", - "rrset_ttl": 300, - "rrset_name": "ptr", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/ptr/PTR", - "rrset_values": [ - "foo.bar.com." - ] - }, - { - "rrset_type": "SPF", - "rrset_ttl": 600, - "rrset_name": "spf", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/spf/SPF", - "rrset_values": [ - "\"v=spf1 ip4:192.168.0.1/16-all\"" - ] - }, - { - "rrset_type": "TXT", - "rrset_ttl": 600, - "rrset_name": "txt", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/txt/TXT", - "rrset_values": [ - "\"Bah bah black sheep\"", - "\"have you any wool.\"", - "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"" - ] - }, - { - "rrset_type": "A", - "rrset_ttl": 300, - "rrset_name": "www", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/A", - "rrset_values": [ - "2.2.3.6" - ] - }, - { - "rrset_type": "A", - "rrset_ttl": 300, - "rrset_name": "www.sub", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www.sub/A", - "rrset_values": [ - "2.2.3.6" - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 600, - "rrset_name": "_imap._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV", - "rrset_values": [ - "0 0 0 ." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 600, - "rrset_name": "_pop3._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV", - "rrset_values": [ - "0 0 0 ." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 600, - "rrset_name": "_srv._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_srv._tcp/SRV", - "rrset_values": [ - "10 20 30 foo-1.unit.tests.", - "12 20 30 foo-2.unit.tests." - ] - } - ] diff --git a/tests/fixtures/gandi-records.json b/tests/fixtures/gandi-records.json deleted file mode 100644 index 01d30f7..0000000 --- a/tests/fixtures/gandi-records.json +++ /dev/null @@ -1,111 +0,0 @@ -[ - { - "rrset_type": "A", - "rrset_ttl": 10800, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", - "rrset_values": [ - "217.70.184.38" - ] - }, - { - "rrset_type": "MX", - "rrset_ttl": 10800, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/MX", - "rrset_values": [ - "10 spool.mail.gandi.net.", - "50 fb.mail.gandi.net." - ] - }, - { - "rrset_type": "TXT", - "rrset_ttl": 10800, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/TXT", - "rrset_values": [ - "\"v=spf1 include:_mailcust.gandi.net ?all\"" - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 10800, - "rrset_name": "webmail", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/webmail/CNAME", - "rrset_values": [ - "webmail.gandi.net." - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 10800, - "rrset_name": "www", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/CNAME", - "rrset_values": [ - "webredir.vip.gandi.net." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_imap._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV", - "rrset_values": [ - "0 0 0 ." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_imaps._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imaps._tcp/SRV", - "rrset_values": [ - "0 1 993 mail.gandi.net." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_pop3._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV", - "rrset_values": [ - "0 0 0 ." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_pop3s._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3s._tcp/SRV", - "rrset_values": [ - "10 1 995 mail.gandi.net." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_submission._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_submission._tcp/SRV", - "rrset_values": [ - "0 1 465 mail.gandi.net." - ] - }, - { - "rrset_type": "CDS", - "rrset_ttl": 10800, - "rrset_name": "sub", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/sub/CDS", - "rrset_values": [ - "32128 13 1 6823D9BB1B03DF714DD0EB163E20B341C96D18C0" - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 10800, - "rrset_name": "relative", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/relative/CNAME", - "rrset_values": [ - "target" - ] - } -] diff --git a/tests/fixtures/gandi-zone.json b/tests/fixtures/gandi-zone.json deleted file mode 100644 index e132f4c..0000000 --- a/tests/fixtures/gandi-zone.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain_keys_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/keys", - "fqdn": "unit.tests", - "automatic_snapshots": true, - "domain_records_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records", - "domain_href": "https://api.gandi.net/v5/livedns/domains/unit.tests" -} \ No newline at end of file diff --git a/tests/test_octodns_provider_azuredns.py b/tests/test_octodns_provider_azuredns.py index 60b1386..4990ad3 100644 --- a/tests/test_octodns_provider_azuredns.py +++ b/tests/test_octodns_provider_azuredns.py @@ -5,2464 +5,12 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from octodns.record import Create, Update, Delete, Record -from octodns.provider.azuredns import _AzureRecord, AzureProvider, \ - _check_endswith_dot, _parse_azure_type, _root_traffic_manager_name, \ - _get_monitor, _profile_is_match, AzureException -from octodns.zone import Zone -from octodns.provider.base import Plan - -from azure.mgmt.dns.models import ARecord, AaaaRecord, CaaRecord, \ - CnameRecord, MxRecord, SrvRecord, NsRecord, PtrRecord, TxtRecord, \ - RecordSet, SoaRecord, SubResource, Zone as AzureZone -from azure.mgmt.trafficmanager.models import Profile, DnsConfig, \ - MonitorConfig, Endpoint, MonitorConfigCustomHeadersItem -from msrestazure.azure_exceptions import CloudError - from unittest import TestCase -from mock import Mock, patch, call -zone = Zone(name='unit.tests.', sub_zones=[]) -octo_records = [] -octo_records.append(Record.new(zone, '', { - 'ttl': 0, - 'type': 'A', - 'values': ['1.2.3.4', '10.10.10.10']})) -octo_records.append(Record.new(zone, 'a', { - 'ttl': 1, - 'type': 'A', - 'values': ['1.2.3.4', '1.1.1.1']})) -octo_records.append(Record.new(zone, 'aa', { - 'ttl': 9001, - 'type': 'A', - 'values': ['1.2.4.3']})) -octo_records.append(Record.new(zone, 'aaa', { - 'ttl': 2, - 'type': 'A', - 'values': ['1.1.1.3']})) -octo_records.append(Record.new(zone, 'aaaa1', { - 'ttl': 300, - 'type': 'AAAA', - 'values': ['2601:644:500:e210:62f8:1dff:feb8:947a', - '2601:642:500:e210:62f8:1dff:feb8:947a'], -})) -octo_records.append(Record.new(zone, 'aaaa2', { - 'ttl': 300, - 'type': 'AAAA', - 'value': '2601:644:500:e210:62f8:1dff:feb8:947a' -})) -octo_records.append(Record.new(zone, 'caa1', { - 'ttl': 9, - 'type': 'CAA', - 'value': { - 'flags': 0, - 'tag': 'issue', - 'value': 'ca.unit.tests', - }})) -octo_records.append(Record.new(zone, 'caa2', { - 'ttl': 9, - 'type': 'CAA', - 'values': [{ - 'flags': 0, - 'tag': 'issue', - 'value': 'ca1.unit.tests', - }, { - 'flags': 0, - 'tag': 'issue', - 'value': 'ca2.unit.tests', - }]})) -octo_records.append(Record.new(zone, 'cname', { - 'ttl': 3, - 'type': 'CNAME', - 'value': 'a.unit.tests.'})) -octo_records.append(Record.new(zone, 'mx1', { - 'ttl': 3, - 'type': 'MX', - 'values': [{ - 'priority': 10, - 'value': 'mx1.unit.tests.', - }, { - 'priority': 20, - 'value': 'mx2.unit.tests.', - }]})) -octo_records.append(Record.new(zone, 'mx2', { - 'ttl': 3, - 'type': 'MX', - 'values': [{ - 'priority': 10, - 'value': 'mx1.unit.tests.', - }]})) -octo_records.append(Record.new(zone, '', { - 'ttl': 4, - 'type': 'NS', - 'values': ['ns1.unit.tests.', 'ns2.unit.tests.']})) -octo_records.append(Record.new(zone, 'foo', { - 'ttl': 5, - 'type': 'NS', - 'value': 'ns1.unit.tests.'})) -octo_records.append(Record.new(zone, 'ptr1', { - 'ttl': 5, - 'type': 'PTR', - 'value': 'ptr1.unit.tests.'})) -octo_records.append(Record.new(zone, '_srv._tcp', { - 'ttl': 6, - 'type': 'SRV', - 'values': [{ - 'priority': 10, - 'weight': 20, - 'port': 30, - 'target': 'foo-1.unit.tests.', - }, { - 'priority': 12, - 'weight': 30, - 'port': 30, - 'target': 'foo-2.unit.tests.', - }]})) -octo_records.append(Record.new(zone, '_srv2._tcp', { - 'ttl': 7, - 'type': 'SRV', - 'values': [{ - 'priority': 12, - 'weight': 17, - 'port': 1, - 'target': 'srvfoo.unit.tests.', - }]})) -octo_records.append(Record.new(zone, 'txt1', { - 'ttl': 8, - 'type': 'TXT', - 'value': 'txt singleton test'})) -octo_records.append(Record.new(zone, 'txt2', { - 'ttl': 9, - 'type': 'TXT', - 'values': ['txt multiple test', 'txt multiple test 2']})) +class TestAzureShim(TestCase): -long_txt = "v=spf1 ip4:10.10.0.0/24 ip4:10.10.1.0/24 ip4:10.10.2.0/24" -long_txt += " ip4:10.10.3.0/24 ip4:10.10.4.0/24 ip4:10.10.5.0/24 " -long_txt += " 10.6.0/24 ip4:10.10.7.0/24 ip4:10.10.8.0/24 " -long_txt += " ip4:10.10.10.0/24 ip4:10.10.11.0/24 ip4:10.10.12.0/24" -long_txt += " ip4:10.10.13.0/24 ip4:10.10.14.0/24 ip4:10.10.15.0/24" -long_txt += " ip4:10.10.16.0/24 ip4:10.10.17.0/24 ip4:10.10.18.0/24" -long_txt += " ip4:10.10.19.0/24 ip4:10.10.20.0/24 ~all" -octo_records.append(Record.new(zone, 'txt3', { - 'ttl': 10, - 'type': 'TXT', - 'values': ['txt multiple test', long_txt]})) - -octo_records.append(Record.new(zone, 'ptr2', { - 'ttl': 11, - 'type': 'PTR', - 'values': ['ptr21.unit.tests.', 'ptr22.unit.tests.']})) - -azure_records = [] -_base0 = _AzureRecord('TestAzure', octo_records[0]) -_base0.zone_name = 'unit.tests' -_base0.relative_record_set_name = '@' -_base0.record_type = 'A' -_base0.params['ttl'] = 0 -_base0.params['a_records'] = [ARecord(ipv4_address='1.2.3.4'), - ARecord(ipv4_address='10.10.10.10')] -azure_records.append(_base0) - -_base1 = _AzureRecord('TestAzure', octo_records[1]) -_base1.zone_name = 'unit.tests' -_base1.relative_record_set_name = 'a' -_base1.record_type = 'A' -_base1.params['ttl'] = 1 -_base1.params['a_records'] = [ARecord(ipv4_address='1.2.3.4'), - ARecord(ipv4_address='1.1.1.1')] -azure_records.append(_base1) - -_base2 = _AzureRecord('TestAzure', octo_records[2]) -_base2.zone_name = 'unit.tests' -_base2.relative_record_set_name = 'aa' -_base2.record_type = 'A' -_base2.params['ttl'] = 9001 -_base2.params['a_records'] = ARecord(ipv4_address='1.2.4.3') -azure_records.append(_base2) - -_base3 = _AzureRecord('TestAzure', octo_records[3]) -_base3.zone_name = 'unit.tests' -_base3.relative_record_set_name = 'aaa' -_base3.record_type = 'A' -_base3.params['ttl'] = 2 -_base3.params['a_records'] = ARecord(ipv4_address='1.1.1.3') -azure_records.append(_base3) - -_base4 = _AzureRecord('TestAzure', octo_records[4]) -_base4.zone_name = 'unit.tests' -_base4.relative_record_set_name = 'aaaa1' -_base4.record_type = 'AAAA' -_base4.params['ttl'] = 300 -aaaa1 = AaaaRecord(ipv6_address='2601:644:500:e210:62f8:1dff:feb8:947a') -aaaa2 = AaaaRecord(ipv6_address='2601:642:500:e210:62f8:1dff:feb8:947a') -_base4.params['aaaa_records'] = [aaaa1, aaaa2] -azure_records.append(_base4) - -_base5 = _AzureRecord('TestAzure', octo_records[5]) -_base5.zone_name = 'unit.tests' -_base5.relative_record_set_name = 'aaaa2' -_base5.record_type = 'AAAA' -_base5.params['ttl'] = 300 -_base5.params['aaaa_records'] = [aaaa1] -azure_records.append(_base5) - -_base6 = _AzureRecord('TestAzure', octo_records[6]) -_base6.zone_name = 'unit.tests' -_base6.relative_record_set_name = 'caa1' -_base6.record_type = 'CAA' -_base6.params['ttl'] = 9 -_base6.params['caa_records'] = [CaaRecord(flags=0, - tag='issue', - value='ca.unit.tests')] -azure_records.append(_base6) - -_base7 = _AzureRecord('TestAzure', octo_records[7]) -_base7.zone_name = 'unit.tests' -_base7.relative_record_set_name = 'caa2' -_base7.record_type = 'CAA' -_base7.params['ttl'] = 9 -_base7.params['caa_records'] = [CaaRecord(flags=0, - tag='issue', - value='ca1.unit.tests'), - CaaRecord(flags=0, - tag='issue', - value='ca2.unit.tests')] -azure_records.append(_base7) - -_base8 = _AzureRecord('TestAzure', octo_records[8]) -_base8.zone_name = 'unit.tests' -_base8.relative_record_set_name = 'cname' -_base8.record_type = 'CNAME' -_base8.params['ttl'] = 3 -_base8.params['cname_record'] = CnameRecord(cname='a.unit.tests.') -azure_records.append(_base8) - -_base9 = _AzureRecord('TestAzure', octo_records[9]) -_base9.zone_name = 'unit.tests' -_base9.relative_record_set_name = 'mx1' -_base9.record_type = 'MX' -_base9.params['ttl'] = 3 -_base9.params['mx_records'] = [MxRecord(preference=10, - exchange='mx1.unit.tests.'), - MxRecord(preference=20, - exchange='mx2.unit.tests.')] -azure_records.append(_base9) - -_base10 = _AzureRecord('TestAzure', octo_records[10]) -_base10.zone_name = 'unit.tests' -_base10.relative_record_set_name = 'mx2' -_base10.record_type = 'MX' -_base10.params['ttl'] = 3 -_base10.params['mx_records'] = [MxRecord(preference=10, - exchange='mx1.unit.tests.')] -azure_records.append(_base10) - -_base11 = _AzureRecord('TestAzure', octo_records[11]) -_base11.zone_name = 'unit.tests' -_base11.relative_record_set_name = '@' -_base11.record_type = 'NS' -_base11.params['ttl'] = 4 -_base11.params['ns_records'] = [NsRecord(nsdname='ns1.unit.tests.'), - NsRecord(nsdname='ns2.unit.tests.')] -azure_records.append(_base11) - -_base12 = _AzureRecord('TestAzure', octo_records[12]) -_base12.zone_name = 'unit.tests' -_base12.relative_record_set_name = 'foo' -_base12.record_type = 'NS' -_base12.params['ttl'] = 5 -_base12.params['ns_records'] = [NsRecord(nsdname='ns1.unit.tests.')] -azure_records.append(_base12) - -_base13 = _AzureRecord('TestAzure', octo_records[13]) -_base13.zone_name = 'unit.tests' -_base13.relative_record_set_name = 'ptr1' -_base13.record_type = 'PTR' -_base13.params['ttl'] = 5 -_base13.params['ptr_records'] = [PtrRecord(ptrdname='ptr1.unit.tests.')] -azure_records.append(_base13) - -_base14 = _AzureRecord('TestAzure', octo_records[14]) -_base14.zone_name = 'unit.tests' -_base14.relative_record_set_name = '_srv._tcp' -_base14.record_type = 'SRV' -_base14.params['ttl'] = 6 -_base14.params['srv_records'] = [SrvRecord(priority=10, - weight=20, - port=30, - target='foo-1.unit.tests.'), - SrvRecord(priority=12, - weight=30, - port=30, - target='foo-2.unit.tests.')] -azure_records.append(_base14) - -_base15 = _AzureRecord('TestAzure', octo_records[15]) -_base15.zone_name = 'unit.tests' -_base15.relative_record_set_name = '_srv2._tcp' -_base15.record_type = 'SRV' -_base15.params['ttl'] = 7 -_base15.params['srv_records'] = [SrvRecord(priority=12, - weight=17, - port=1, - target='srvfoo.unit.tests.')] -azure_records.append(_base15) - -_base16 = _AzureRecord('TestAzure', octo_records[16]) -_base16.zone_name = 'unit.tests' -_base16.relative_record_set_name = 'txt1' -_base16.record_type = 'TXT' -_base16.params['ttl'] = 8 -_base16.params['txt_records'] = [TxtRecord(value=['txt singleton test'])] -azure_records.append(_base16) - -_base17 = _AzureRecord('TestAzure', octo_records[17]) -_base17.zone_name = 'unit.tests' -_base17.relative_record_set_name = 'txt2' -_base17.record_type = 'TXT' -_base17.params['ttl'] = 9 -_base17.params['txt_records'] = [TxtRecord(value=['txt multiple test']), - TxtRecord(value=['txt multiple test 2'])] -azure_records.append(_base17) - -long_txt_az1 = "v=spf1 ip4:10.10.0.0/24 ip4:10.10.1.0/24 ip4:10.10.2.0/24" -long_txt_az1 += " ip4:10.10.3.0/24 ip4:10.10.4.0/24 ip4:10.10.5.0/24 " -long_txt_az1 += " 10.6.0/24 ip4:10.10.7.0/24 ip4:10.10.8.0/24 " -long_txt_az1 += " ip4:10.10.10.0/24 ip4:10.10.11.0/24 ip4:10.10.12.0/24" -long_txt_az1 += " ip4:10.10.13.0/24 ip4:10.10.14.0/24 ip4:10.10." -long_txt_az2 = "15.0/24 ip4:10.10.16.0/24 ip4:10.10.17.0/24 ip4:10.10.18.0/24" -long_txt_az2 += " ip4:10.10.19.0/24 ip4:10.10.20.0/24 ~all" -_base18 = _AzureRecord('TestAzure', octo_records[18]) -_base18.zone_name = 'unit.tests' -_base18.relative_record_set_name = 'txt3' -_base18.record_type = 'TXT' -_base18.params['ttl'] = 10 -_base18.params['txt_records'] = [TxtRecord(value=['txt multiple test']), - TxtRecord(value=[long_txt_az1, long_txt_az2])] -azure_records.append(_base18) - -_base19 = _AzureRecord('TestAzure', octo_records[19]) -_base19.zone_name = 'unit.tests' -_base19.relative_record_set_name = 'ptr2' -_base19.record_type = 'PTR' -_base19.params['ttl'] = 11 -_base19.params['ptr_records'] = [PtrRecord(ptrdname='ptr21.unit.tests.'), - PtrRecord(ptrdname='ptr22.unit.tests.')] -azure_records.append(_base19) - - -class Test_AzureRecord(TestCase): - def test_azure_record(self): - assert(len(azure_records) == len(octo_records)) - for i in range(len(azure_records)): - octo = _AzureRecord('TestAzure', octo_records[i]) - assert(azure_records[i]._equals(octo)) - - -class Test_DynamicAzureRecord(TestCase): - def test_azure_record(self): - tm_profile = Profile() - data = { - 'ttl': 60, - 'type': 'CNAME', - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': 'one.unit.tests.', 'weight': 1} - ], - 'fallback': 'two', - }, - 'two': { - 'values': [ - {'value': 'two.unit.tests.', 'weight': 1} - ], - }, - }, - 'rules': [ - {'geos': ['AF'], 'pool': 'one'}, - {'pool': 'two'}, - ], - } - } - octo_record = Record.new(zone, 'foo', data) - azure_record = _AzureRecord('TestAzure', octo_record, - traffic_manager=tm_profile) - self.assertEqual(azure_record.zone_name, zone.name[:-1]) - self.assertEqual(azure_record.relative_record_set_name, 'foo') - self.assertEqual(azure_record.record_type, 'CNAME') - self.assertEqual(azure_record.params['ttl'], 60) - self.assertEqual(azure_record.params['target_resource'], tm_profile) - - -class Test_ParseAzureType(TestCase): - def test_parse_azure_type(self): - for expected, test in [['A', 'Microsoft.Network/dnszones/A'], - ['AAAA', 'Microsoft.Network/dnszones/AAAA'], - ['NS', 'Microsoft.Network/dnszones/NS'], - ['MX', 'Microsoft.Network/dnszones/MX']]: - self.assertEquals(expected, _parse_azure_type(test)) - - -class Test_CheckEndswithDot(TestCase): - def test_check_endswith_dot(self): - for expected, test in [['a.', 'a'], - ['a.', 'a.'], - ['foo.bar.', 'foo.bar.'], - ['foo.bar.', 'foo.bar']]: - self.assertEquals(expected, _check_endswith_dot(test)) - - -class Test_RootTrafficManagerName(TestCase): - def test_root_traffic_manager_name(self): - test = Record.new(zone, 'foo', data={ - 'ttl': 60, 'type': 'CNAME', 'value': 'default.unit.tests.', - }) - self.assertEqual(_root_traffic_manager_name(test), 'foo--unit--tests') - - -class Test_GetMonitor(TestCase): - def test_get_monitor(self): - record = Record.new(zone, 'foo', data={ - 'type': 'CNAME', 'ttl': 60, 'value': 'default.unit.tests.', - 'octodns': { - 'healthcheck': { - 'path': '/_ping', - 'port': 4443, - 'protocol': 'HTTPS', - } - }, - }) - - monitor = _get_monitor(record) - self.assertEqual(monitor.protocol, 'HTTPS') - self.assertEqual(monitor.port, 4443) - self.assertEqual(monitor.path, '/_ping') - headers = monitor.custom_headers - self.assertIsInstance(headers, list) - self.assertEquals(len(headers), 1) - headers = headers[0] - self.assertEqual(headers.name, 'Host') - self.assertEqual(headers.value, record.healthcheck_host()) - - # test TCP monitor - record._octodns['healthcheck']['protocol'] = 'TCP' - monitor = _get_monitor(record) - self.assertEqual(monitor.protocol, 'TCP') - self.assertIsNone(monitor.custom_headers) - - -class Test_ProfileIsMatch(TestCase): - def test_profile_is_match(self): - is_match = _profile_is_match - - self.assertFalse(is_match(None, Profile())) - - # Profile object builder with default property values that can be - # overridden for testing below - def profile( - name = 'foo-unit-tests', - ttl = 60, - method = 'Geographic', - dns_name = None, - monitor_proto = 'HTTPS', - monitor_port = 4443, - monitor_path = '/_ping', - monitor_interval_in_seconds = None, - monitor_timeout_in_seconds = None, - monitor_tolerated_number_of_failures = None, - endpoints = 1, - endpoint_name = 'name', - endpoint_type = 'profile/nestedEndpoints', - endpoint_status = None, - target = 'target.unit.tests', - target_id = 'resource/id', - geos = ['GEO-AF'], - weight = 1, - priority = 1, - ): - dns = DnsConfig(relative_name=(dns_name or name), ttl=ttl) - return Profile( - name=name, traffic_routing_method=method, dns_config=dns, - monitor_config=MonitorConfig( - protocol=monitor_proto, - port=monitor_port, - path=monitor_path, - interval_in_seconds=monitor_interval_in_seconds, - timeout_in_seconds=monitor_timeout_in_seconds, - tolerated_number_of_failures= - monitor_tolerated_number_of_failures, - ), - endpoints=[Endpoint( - name=endpoint_name, - type=endpoint_type, - endpoint_status=endpoint_status, - target=target, - target_resource_id=target_id, - geo_mapping=geos, - weight=weight, - priority=priority, - )] + [Endpoint()] * (endpoints - 1), - ) - - self.assertTrue(is_match(profile(), profile())) - - self.assertFalse(is_match(profile(), profile(name='two'))) - self.assertFalse(is_match(profile(), profile(endpoints=2))) - self.assertFalse(is_match(profile(), profile(dns_name='two'))) - self.assertFalse(is_match(profile(), profile(monitor_proto='HTTP'))) - self.assertFalse(is_match( - profile(), - profile(monitor_interval_in_seconds=9), - )) - self.assertFalse(is_match( - profile(), - profile(monitor_timeout_in_seconds=3), - )) - self.assertFalse(is_match( - profile(), - profile(monitor_tolerated_number_of_failures=2), - )) - self.assertFalse(is_match(profile(), profile(endpoint_name='a'))) - self.assertFalse(is_match(profile(), profile(endpoint_type='b'))) - self.assertFalse( - is_match(profile(), profile(endpoint_status='Disabled')) - ) - self.assertFalse( - is_match(profile(endpoint_type='b'), profile(endpoint_type='b')) - ) - self.assertFalse(is_match(profile(), profile(target_id='rsrc/id2'))) - self.assertFalse(is_match(profile(), profile(geos=['IN']))) - self.assertFalse(is_match( - profile(endpoint_type='profile/externalEndpoints'), - profile( - endpoint_type='profile/externalEndpoints', - geos=['IN'] - ) - )) - self.assertFalse(is_match(profile(method='Priority'), profile( - method='Priority', priority=2 - ))) - - def wprofile(**kwargs): - kwargs['method'] = 'Weighted' - kwargs['endpoint_type'] = 'profile/externalEndpoints' - return profile(**kwargs) - - self.assertFalse(is_match(wprofile(), wprofile(target='bar.unit'))) - self.assertFalse(is_match(wprofile(), wprofile(weight=3))) - - -class TestAzureDnsProvider(TestCase): - def _provider(self): - return self._get_provider('mock_spc', 'mock_dns_client') - - @patch('octodns.provider.azuredns.TrafficManagerManagementClient') - @patch('octodns.provider.azuredns.DnsManagementClient') - @patch('octodns.provider.azuredns.ClientSecretCredential') - @patch('octodns.provider.azuredns.ServicePrincipalCredentials') - def _get_provider(self, mock_spc, mock_css, mock_dns_client, - mock_tm_client): - '''Returns a mock AzureProvider object to use in testing. - - :param mock_spc: placeholder - :type mock_spc: str - :param mock_dns_client: placeholder - :type mock_dns_client: str - :param mock_tm_client: placeholder - :type mock_tm_client: str - - :type return: AzureProvider - ''' - provider = AzureProvider('mock_id', 'mock_client', 'mock_key', - 'mock_directory', 'mock_sub', 'mock_rg' - ) - - # Fetch the client to force it to load the creds - provider._dns_client - - # set critical functions to return properly - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = [] - tm_sync = provider._tm_client.profiles.create_or_update - - def side_effect(rg, name, profile): - return Profile( - id=profile.id, - name=profile.name, - traffic_routing_method=profile.traffic_routing_method, - dns_config=profile.dns_config, - monitor_config=profile.monitor_config, - endpoints=profile.endpoints, - ) - - tm_sync.side_effect = side_effect - - return provider - - def _get_dynamic_record(self, zone): - return Record.new(zone, 'foo', data={ - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': 'one.unit.tests.', 'weight': 1}, - ], - 'fallback': 'two', - }, - 'two': { - 'values': [ - {'value': 'two1.unit.tests.', 'weight': 3}, - {'value': 'two2.unit.tests.', 'weight': 4}, - ], - 'fallback': 'three', - }, - 'three': { - 'values': [ - {'value': 'three.unit.tests.', 'weight': 1}, - ], - }, - }, - 'rules': [ - {'geos': ['AF', 'EU-DE', 'NA-US-CA', 'OC'], 'pool': 'one'}, - {'pool': 'two'}, - ], - }, - 'octodns': { - 'healthcheck': { - 'path': '/_ping', - 'port': 4443, - 'protocol': 'HTTPS', - } - }, - }) - - def _get_tm_profiles(self, provider): - sub = provider._dns_client_subscription_id - rg = provider._resource_group - base_id = '/subscriptions/' + sub + \ - '/resourceGroups/' + rg + \ - '/providers/Microsoft.Network/trafficManagerProfiles/' - prefix = 'foo--unit--tests' - name_format = prefix + '-' - id_format = base_id + name_format - - header = MonitorConfigCustomHeadersItem(name='Host', - value='foo.unit.tests') - monitor = MonitorConfig(protocol='HTTPS', port=4443, path='/_ping', - custom_headers=[header]) - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - nested = 'Microsoft.Network/trafficManagerProfiles/nestedEndpoints' - - profiles = [ - Profile( - id=f'{id_format}pool-two', - name=f'{name_format}pool-two', - traffic_routing_method='Weighted', - dns_config=DnsConfig(ttl=60), - monitor_config=monitor, - endpoints=[ - Endpoint( - name='two--two1.unit.tests', - type=external, - target='two1.unit.tests', - weight=3, - ), - Endpoint( - name='two--two2.unit.tests', - type=external, - target='two2.unit.tests', - weight=4, - ), - ], - ), - Profile( - id=f'{id_format}rule-one', - name=f'{name_format}rule-one', - traffic_routing_method='Priority', - dns_config=DnsConfig(ttl=60), - monitor_config=monitor, - endpoints=[ - Endpoint( - name='one', - type=external, - target='one.unit.tests', - priority=1, - ), - Endpoint( - name='two', - type=nested, - target_resource_id=f'{id_format}pool-two', - priority=2, - ), - Endpoint( - name='three', - type=external, - target='three.unit.tests', - priority=3, - ), - Endpoint( - name='--default--', - type=external, - target='default.unit.tests', - priority=4, - ), - ], - ), - Profile( - id=f'{id_format}rule-two', - name=f'{name_format}rule-two', - traffic_routing_method='Priority', - dns_config=DnsConfig(ttl=60), - monitor_config=monitor, - endpoints=[ - Endpoint( - name='two', - type=nested, - target_resource_id=f'{id_format}pool-two', - priority=1, - ), - Endpoint( - name='three', - type=external, - target='three.unit.tests', - priority=2, - ), - Endpoint( - name='--default--', - type=external, - target='default.unit.tests', - priority=3, - ), - ], - ), - Profile( - id=base_id + prefix, - name=prefix, - traffic_routing_method='Geographic', - dns_config=DnsConfig(ttl=60), - monitor_config=monitor, - endpoints=[ - Endpoint( - geo_mapping=['GEO-AF', 'DE', 'US-CA', 'GEO-AP'], - name='one', - type=nested, - target_resource_id=f'{id_format}rule-one', - ), - Endpoint( - geo_mapping=['WORLD'], - name='two', - type=nested, - target_resource_id=f'{id_format}rule-two', - ), - ], - ), - ] - - for profile in profiles: - profile.dns_config.relative_name = profile.name - - return profiles - - def _get_dynamic_package(self): - '''Convenience function to setup a sample dynamic record. - ''' - provider = self._get_provider() - - # setup traffic manager profiles - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = self._get_tm_profiles(provider) - - # setup zone with dynamic record - zone = Zone(name='unit.tests.', sub_zones=[]) - record = self._get_dynamic_record(zone) - zone.add_record(record) - - # return everything - return provider, zone, record - - def test_populate_records(self): - provider = self._get_provider() - - rs = [] - recordSet = RecordSet(a_records=[ARecord(ipv4_address='1.1.1.1')]) - recordSet.name, recordSet.ttl, recordSet.type = 'a1', 0, 'A' - recordSet.target_resource = SubResource() - rs.append(recordSet) - recordSet = RecordSet(a_records=[ARecord(ipv4_address='1.1.1.1'), - ARecord(ipv4_address='2.2.2.2')]) - recordSet.name, recordSet.ttl, recordSet.type = 'a2', 1, 'A' - recordSet.target_resource = SubResource() - rs.append(recordSet) - aaaa1 = AaaaRecord(ipv6_address='1:1ec:1::1') - recordSet = RecordSet(aaaa_records=[aaaa1]) - recordSet.name, recordSet.ttl, recordSet.type = 'aaaa1', 2, 'AAAA' - recordSet.target_resource = SubResource() - rs.append(recordSet) - aaaa2 = AaaaRecord(ipv6_address='1:1ec:1::2') - recordSet = RecordSet(aaaa_records=[aaaa1, - aaaa2]) - recordSet.name, recordSet.ttl, recordSet.type = 'aaaa2', 3, 'AAAA' - recordSet.target_resource = SubResource() - rs.append(recordSet) - recordSet = RecordSet(caa_records=[CaaRecord(flags=0, - tag='issue', - value='caa1.unit.tests')]) - recordSet.name, recordSet.ttl, recordSet.type = 'caa1', 4, 'CAA' - rs.append(recordSet) - recordSet = RecordSet(caa_records=[CaaRecord(flags=0, - tag='issue', - value='caa1.unit.tests'), - CaaRecord(flags=0, - tag='issue', - value='caa2.unit.tests')]) - recordSet.name, recordSet.ttl, recordSet.type = 'caa2', 4, 'CAA' - rs.append(recordSet) - cname1 = CnameRecord(cname='cname.unit.test.') - recordSet = RecordSet(cname_record=cname1) - recordSet.name, recordSet.ttl, recordSet.type = 'cname1', 5, 'CNAME' - recordSet.target_resource = SubResource() - rs.append(recordSet) - recordSet = RecordSet(mx_records=[MxRecord(preference=10, - exchange='mx1.unit.test.')]) - recordSet.name, recordSet.ttl, recordSet.type = 'mx1', 7, 'MX' - rs.append(recordSet) - recordSet = RecordSet(mx_records=[MxRecord(preference=10, - exchange='mx1.unit.test.'), - MxRecord(preference=11, - exchange='mx2.unit.test.')]) - recordSet.name, recordSet.ttl, recordSet.type = 'mx2', 8, 'MX' - rs.append(recordSet) - recordSet = RecordSet(ns_records=[NsRecord(nsdname='ns1.unit.test.')]) - recordSet.name, recordSet.ttl, recordSet.type = 'ns1', 9, 'NS' - rs.append(recordSet) - recordSet = RecordSet(ns_records=[NsRecord(nsdname='ns1.unit.test.'), - NsRecord(nsdname='ns2.unit.test.')]) - recordSet.name, recordSet.ttl, recordSet.type = 'ns2', 10, 'NS' - rs.append(recordSet) - ptr1 = PtrRecord(ptrdname='ptr1.unit.test.') - recordSet = RecordSet(ptr_records=[ptr1]) - recordSet.name, recordSet.ttl, recordSet.type = 'ptr1', 11, 'PTR' - rs.append(recordSet) - recordSet = RecordSet(srv_records=[SrvRecord(priority=1, - weight=2, - port=3, - target='1unit.tests.')]) - recordSet.name, recordSet.ttl, recordSet.type = '_srv1._tcp', 13, 'SRV' - rs.append(recordSet) - recordSet = RecordSet(srv_records=[SrvRecord(priority=1, - weight=2, - port=3, - target='1unit.tests.'), - SrvRecord(priority=4, - weight=5, - port=6, - target='2unit.tests.')]) - recordSet.name, recordSet.ttl, recordSet.type = '_srv2._tcp', 14, 'SRV' - rs.append(recordSet) - recordSet = RecordSet(txt_records=[TxtRecord(value='sample text1')]) - recordSet.name, recordSet.ttl, recordSet.type = 'txt1', 15, 'TXT' - recordSet.target_resource = SubResource() - rs.append(recordSet) - recordSet = RecordSet(txt_records=[TxtRecord(value='sample text1'), - TxtRecord(value='sample text2')]) - recordSet.name, recordSet.ttl, recordSet.type = 'txt2', 16, 'TXT' - recordSet.target_resource = SubResource() - rs.append(recordSet) - recordSet = RecordSet(soa_record=[SoaRecord()]) - recordSet.name, recordSet.ttl, recordSet.type = '', 17, 'SOA' - rs.append(recordSet) - long_txt = "v=spf1 ip4:10.10.0.0/24 ip4:10.10.1.0/24 ip4:10.10.2.0/24" - long_txt += " ip4:10.10.3.0/24 ip4:10.10.4.0/24 ip4:10.10.5.0/24 " - long_txt += " 10.6.0/24 ip4:10.10.7.0/24 ip4:10.10.8.0/24 " - long_txt += " ip4:10.10.10.0/24 ip4:10.10.11.0/24 ip4:10.10.12.0/24" - long_txt += " ip4:10.10.13.0/24 ip4:10.10.14.0/24 ip4:10.10.15.0/24" - long_txt += " ip4:10.10.16.0/24 ip4:10.10.17.0/24 ip4:10.10.18.0/24" - long_txt += " ip4:10.10.19.0/24 ip4:10.10.20.0/24 ~all" - recordSet = RecordSet(txt_records=[TxtRecord(value='sample value1'), - TxtRecord(value=long_txt)]) - recordSet.name, recordSet.ttl, recordSet.type = 'txt3', 18, 'TXT' - recordSet.target_resource = SubResource() - rs.append(recordSet) - - record_list = provider._dns_client.record_sets.list_by_dns_zone - record_list.return_value = rs - - zone_list = provider._dns_client.zones.list_by_resource_group - zone_list.return_value = [zone] - - exists = provider.populate(zone) - - self.assertEquals(len(zone.records), 17) - self.assertTrue(exists) - - def test_populate_zone(self): - provider = self._get_provider() - - zone_list = provider._dns_client.zones.list_by_resource_group - zone_1 = AzureZone(location='global') - # This is far from ideal but the - # zone constructor doesn't let me set it on creation - zone_1.name = "zone-1" - zone_2 = AzureZone(location='global') - # This is far from ideal but the - # zone constructor doesn't let me set it on creation - zone_2.name = "zone-2" - zone_list.return_value = [zone_1, - zone_2, - zone_1] - - provider._populate_zones() - - # This should be returning two zones since two zones are the same - self.assertEquals(len(provider._azure_zones), 2) - - def test_bad_zone_response(self): - provider = self._get_provider() - - _get = provider._dns_client.zones.get - _get.side_effect = CloudError(Mock(status=404), 'Azure Error') - self.assertEquals( - provider._check_zone('unit.test', create=False), - None - ) - - def test_extra_changes(self): - provider, existing, record = self._get_dynamic_package() - - # test simple records produce no extra changes - desired = Zone(name=existing.name, sub_zones=[]) - simple = Record.new(desired, 'simple', data={ - 'type': record._type, - 'ttl': record.ttl, - 'value': record.value, - }) - desired.add_record(simple) - extra = provider._extra_changes(desired, desired, [Create(simple)]) - self.assertEqual(len(extra), 0) - - # test an unchanged dynamic record produces no extra changes - desired.add_record(record) - extra = provider._extra_changes(existing, desired, []) - self.assertEqual(len(extra), 0) - - # test unused TM produces the extra change for clean up - sample_profile = self._get_tm_profiles(provider)[0] - tm_id = provider._profile_name_to_id - root_profile_name = _root_traffic_manager_name(record) - extra_profile = Profile( - id=tm_id(f'{root_profile_name}-pool-random'), - name=f'{root_profile_name}-pool-random', - traffic_routing_method='Weighted', - dns_config=sample_profile.dns_config, - monitor_config=sample_profile.monitor_config, - endpoints=sample_profile.endpoints, - ) - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value.append(extra_profile) - provider._populate_traffic_managers() - extra = provider._extra_changes(existing, desired, []) - self.assertEqual(len(extra), 1) - extra = extra[0] - self.assertIsInstance(extra, Update) - self.assertEqual(extra.new, record) - desired._remove_record(record) - tm_list.return_value.pop() - - # test new dynamic record does not produce an extra change for it - new_dynamic = Record.new(desired, record.name + '2', data={ - 'type': record._type, - 'ttl': record.ttl, - 'value': record.value, - 'dynamic': record.dynamic._data(), - 'octodns': record._octodns, - }) - # test change in healthcheck by using a different port number - update_dynamic = Record.new(desired, record.name, data={ - 'type': record._type, - 'ttl': record.ttl, - 'value': record.value, - 'dynamic': record.dynamic._data(), - 'octodns': { - 'healthcheck': { - 'path': '/_ping', - 'port': 443, - 'protocol': 'HTTPS', - }, - }, - }) - desired.add_record(new_dynamic) - desired.add_record(update_dynamic) - changes = [Create(new_dynamic)] - extra = provider._extra_changes(existing, desired, changes) - # implicitly asserts that new_dynamic was not added to extra changes - # as it was already in the `changes` list - self.assertEqual(len(extra), 1) - extra = extra[0] - self.assertIsInstance(extra, Update) - self.assertEqual(extra.new, update_dynamic) - - # test dynamic record of unsupported type throws exception - unsupported_dynamic = Record.new(desired, record.name + '3', data={ - 'type': 'DNAME', - 'ttl': record.ttl, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': 'one.unit.tests.'}]}, - }, - 'rules': [ - {'pool': 'one'}, - ], - }, - }) - desired.add_record(unsupported_dynamic) - changes = [Create(unsupported_dynamic)] - with self.assertRaises(AzureException) as ctx: - provider._extra_changes(existing, desired, changes) - self.assertTrue(str(ctx).endswith('must be of type CNAME')) - desired._remove_record(unsupported_dynamic) - - # test colliding ATM names throws exception - record1 = Record.new(desired, 'sub.www', data={ - 'type': record._type, - 'ttl': record.ttl, - 'value': record.value, - 'dynamic': record.dynamic._data(), - }) - record2 = Record.new(desired, 'sub--www', data={ - 'type': record._type, - 'ttl': record.ttl, - 'value': record.value, - 'dynamic': record.dynamic._data(), - }) - desired.add_record(record1) - desired.add_record(record2) - changes = [Create(record1), Create(record2)] - with self.assertRaises(AzureException) as ctx: - provider._extra_changes(existing, desired, changes) - self.assertTrue(str(ctx) - .startswith('Collision in Traffic Manager')) - - @patch( - 'octodns.provider.azuredns.AzureProvider._generate_traffic_managers') - def test_extra_changes_non_last_fallback_contains_default(self, mock_gtm): - provider = self._get_provider() - - desired = Zone(zone.name, sub_zones=[]) - record = Record.new(desired, 'foo', { - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'one': { - 'values': [{'value': 'one.unit.tests.'}], - 'fallback': 'def', - }, - 'def': { - 'values': [{'value': 'default.unit.tests.'}], - 'fallback': 'two', - }, - 'two': { - 'values': [{'value': 'two.unit.tests.'}], - }, - }, - 'rules': [ - {'pool': 'one'}, - ] - } - }) - desired.add_record(record) - changes = [Create(record)] - - # assert that no exception is raised - provider._extra_changes(zone, desired, changes) - - # simulate duplicate endpoint and assert exception - endpoint = Endpoint(target='dup.unit.tests.') - mock_gtm.return_value = [Profile( - name='test-profile', - endpoints=[endpoint, endpoint], - )] - with self.assertRaises(AzureException) as ctx: - provider._extra_changes(zone, desired, changes) - self.assertTrue('duplicate endpoint' in str(ctx)) - - def test_extra_changes_A_multi_defaults(self): - provider = self._get_provider() - - record = Record.new(zone, 'foo', data={ - 'type': 'A', - 'ttl': 60, - 'values': ['1.1.1.1', '8.8.8.8'], - 'dynamic': { - 'pools': { - 'one': { - 'values': [{'value': '1.1.1.1'}], - }, - }, - 'rules': [ - {'pool': 'one'}, - ], - } - }) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - with self.assertRaises(AzureException) as ctx: - provider._extra_changes(zone, desired, []) - self.assertEqual('single value' in str(ctx)) - - def test_generate_tm_profile(self): - provider, zone, record = self._get_dynamic_package() - profile_gen = provider._generate_tm_profile - - label = 'foobar' - routing = 'Priority' - endpoints = [ - Endpoint(target='one.unit.tests'), - Endpoint(target_resource_id='/s/1/rg/foo/tm/foobar2'), - Endpoint(name='invalid'), - ] - - # invalid endpoint raises exception - with self.assertRaises(AzureException): - profile_gen(routing, endpoints, record, label) - - # regular test - endpoints.pop() - profile = profile_gen(routing, endpoints, record, label) - - # implicitly tests _profile_name_to_id - sub = provider._dns_client_subscription_id - rg = provider._resource_group - expected_name = 'foo--unit--tests-rule-foobar' - expected_id = '/subscriptions/' + sub + \ - '/resourceGroups/' + rg + \ - '/providers/Microsoft.Network/trafficManagerProfiles/' + \ - expected_name - self.assertEqual(profile.id, expected_id) - self.assertEqual(profile.name, expected_name) - self.assertEqual(profile.name, profile.dns_config.relative_name) - self.assertEqual(profile.traffic_routing_method, routing) - self.assertEqual(profile.dns_config.ttl, record.ttl) - self.assertEqual(len(profile.endpoints), len(endpoints)) - - self.assertEqual( - profile.endpoints[0].type, - 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - ) - self.assertEqual( - profile.endpoints[1].type, - 'Microsoft.Network/trafficManagerProfiles/nestedEndpoints' - ) - - def test_dynamic_record(self): - provider, zone, record = self._get_dynamic_package() - profiles = provider._generate_traffic_managers(record) - - # check that every profile is a match with what we expect - expected_profiles = self._get_tm_profiles(provider) - self.assertEqual(len(expected_profiles), len(profiles)) - for have, expected in zip(profiles, expected_profiles): - self.assertTrue(_profile_is_match(have, expected)) - - # check that dynamic record is populated back from profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_generate_traffic_managers_middle_east(self): - # check Asia/Middle East test case - provider, zone, record = self._get_dynamic_package() - record.dynamic._data()['rules'][0]['geos'].append('AS') - profiles = provider._generate_traffic_managers(record) - self.assertIn('GEO-ME', profiles[-1].endpoints[0].geo_mapping) - self.assertIn('GEO-AS', profiles[-1].endpoints[0].geo_mapping) - - def test_populate_dynamic_middle_east(self): - # Middle east without Asia raises exception - provider, zone, record = self._get_dynamic_package() - tm_suffix = _root_traffic_manager_name(record) - tm_id = provider._profile_name_to_id - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = [ - Profile( - id=tm_id(tm_suffix), - name=tm_suffix, - traffic_routing_method='Geographic', - endpoints=[ - Endpoint( - geo_mapping=['GEO-ME'], - ), - ], - ), - ] - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=tm_id(tm_suffix)), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - with self.assertRaises(AzureException) as ctx: - provider._populate_record(zone, azrecord) - self.assertTrue(str(ctx).startswith( - 'Middle East (GEO-ME) is not supported' - )) - - # valid profiles with Middle East test case - provider, zone, record = self._get_dynamic_package() - geo_profile = provider._get_tm_for_dynamic_record(record) - geo_profile.endpoints[0].geo_mapping.extend(['GEO-ME', 'GEO-AS']) - record = provider._populate_record(zone, azrecord) - self.assertIn('AS', record.dynamic.rules[0].data['geos']) - self.assertNotIn('ME', record.dynamic.rules[0].data['geos']) - - def test_dynamic_no_geo(self): - # test that traffic managers are generated as expected - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': 'one.unit.tests.'}, - ], - }, - }, - 'rules': [ - {'pool': 'one'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 1) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests', - traffic_routing_method='Priority', - dns_config=DnsConfig( - relative_name='foo--unit--tests', ttl=60), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='one', - type=external, - target='one.unit.tests', - priority=1, - ), - Endpoint( - name='--default--', - type=external, - target='default.unit.tests', - priority=2, - ), - ], - ))) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_fallback_is_default(self): - # test that traffic managers are generated as expected - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'def': { - 'values': [ - {'value': 'default.unit.tests.'}, - ], - }, - }, - 'rules': [ - {'geos': ['AF'], 'pool': 'def'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 1) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests', - traffic_routing_method='Geographic', - dns_config=DnsConfig( - relative_name='foo--unit--tests', ttl=60), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='def--default--', - type=external, - target='default.unit.tests', - geo_mapping=['GEO-AF'], - ), - ], - ))) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_pool_contains_default(self): - # test that traffic managers are generated as expected - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - nested = 'Microsoft.Network/trafficManagerProfiles/nestedEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'rr': { - 'values': [ - {'value': 'one.unit.tests.'}, - {'value': 'two.unit.tests.'}, - {'value': 'default.unit.tests.'}, - {'value': 'final.unit.tests.'}, - ], - }, - }, - 'rules': [ - {'geos': ['AF'], 'pool': 'rr'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 2) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests-pool-rr', - traffic_routing_method='Weighted', - dns_config=DnsConfig( - relative_name='foo--unit--tests-pool-rr', ttl=60), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='rr--one.unit.tests', - type=external, - target='one.unit.tests', - weight=1, - ), - Endpoint( - name='rr--two.unit.tests', - type=external, - target='two.unit.tests', - weight=1, - ), - Endpoint( - name='rr--default.unit.tests--default--', - type=external, - target='default.unit.tests', - weight=1, - ), - Endpoint( - name='rr--final.unit.tests', - type=external, - target='final.unit.tests', - weight=1, - ), - ], - ))) - self.assertTrue(_profile_is_match(profiles[1], Profile( - name='foo--unit--tests', - traffic_routing_method='Geographic', - dns_config=DnsConfig( - relative_name='foo--unit--tests', ttl=60), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='rr', - type=nested, - target_resource_id=profiles[0].id, - geo_mapping=['GEO-AF'], - ), - ], - ))) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_pool_contains_default_no_geo(self): - # test that traffic managers are generated as expected - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'rr': { - 'values': [ - {'value': 'one.unit.tests.'}, - {'value': 'two.unit.tests.'}, - {'value': 'default.unit.tests.'}, - {'value': 'final.unit.tests.'}, - ], - }, - }, - 'rules': [ - {'pool': 'rr'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 1) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests', - traffic_routing_method='Weighted', - dns_config=DnsConfig( - relative_name='foo--unit--tests', ttl=60), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='rr--one.unit.tests', - type=external, - target='one.unit.tests', - weight=1, - ), - Endpoint( - name='rr--two.unit.tests', - type=external, - target='two.unit.tests', - weight=1, - ), - Endpoint( - name='rr--default.unit.tests--default--', - type=external, - target='default.unit.tests', - weight=1, - ), - Endpoint( - name='rr--final.unit.tests', - type=external, - target='final.unit.tests', - weight=1, - ), - ], - ))) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_last_pool_contains_default_no_geo(self): - # test that traffic managers are generated as expected - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - nested = 'Microsoft.Network/trafficManagerProfiles/nestedEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'cloud': { - 'values': [ - {'value': 'cloud.unit.tests.'}, - ], - 'fallback': 'rr', - }, - 'rr': { - 'values': [ - {'value': 'one.unit.tests.'}, - {'value': 'two.unit.tests.'}, - {'value': 'default.unit.tests.'}, - {'value': 'final.unit.tests.'}, - ], - }, - }, - 'rules': [ - {'pool': 'cloud'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 2) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests-pool-rr', - traffic_routing_method='Weighted', - dns_config=DnsConfig( - relative_name='foo--unit--tests-pool-rr', ttl=60), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='rr--one.unit.tests', - type=external, - target='one.unit.tests', - weight=1, - ), - Endpoint( - name='rr--two.unit.tests', - type=external, - target='two.unit.tests', - weight=1, - ), - Endpoint( - name='rr--default.unit.tests--default--', - type=external, - target='default.unit.tests', - weight=1, - ), - Endpoint( - name='rr--final.unit.tests', - type=external, - target='final.unit.tests', - weight=1, - ), - ], - ))) - self.assertTrue(_profile_is_match(profiles[1], Profile( - name='foo--unit--tests', - traffic_routing_method='Priority', - dns_config=DnsConfig( - relative_name='foo--unit--tests', ttl=60), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='cloud', - type=external, - target='cloud.unit.tests', - priority=1, - ), - Endpoint( - name='rr', - type=nested, - target_resource_id=profiles[0].id, - priority=2, - ), - ], - ))) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_unique_traffic_managers(self): - record = self._get_dynamic_record(zone) - data = { - 'type': record._type, - 'ttl': record.ttl, - 'value': record.value, - 'dynamic': record.dynamic._data() - } - record_names = [ - 'www.foo', 'www-foo' - ] - provider = self._get_provider() - - seen = set() - for name in record_names: - record = Record.new(zone, name, data=data) - tms = provider._generate_traffic_managers(record) - for tm in tms: - self.assertNotIn(tm.name, seen) - seen.add(tm.name) - - def test_dynamic_reused_pool(self): - # test that traffic managers are generated as expected - provider = self._get_provider() - nested = 'Microsoft.Network/trafficManagerProfiles/nestedEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'sto': { - 'values': [ - {'value': 'sto.unit.tests.'}, - ], - 'fallback': 'iad', - }, - 'iad': { - 'values': [ - {'value': 'iad.unit.tests.'}, - ], - 'fallback': 'lhr', - }, - 'lhr': { - 'values': [ - {'value': 'lhr.unit.tests.'}, - ], - }, - }, - 'rules': [ - {'geos': ['EU'], 'pool': 'iad'}, - {'geos': ['EU-GB'], 'pool': 'lhr'}, - {'geos': ['EU-SE'], 'pool': 'sto'}, - {'pool': 'lhr'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 4) - self.assertTrue(_profile_is_match(profiles[-1], Profile( - name='foo--unit--tests', - traffic_routing_method='Geographic', - dns_config=DnsConfig( - relative_name='foo--unit--tests', ttl=record.ttl), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='iad', - type=nested, - target_resource_id=profiles[0].id, - geo_mapping=['GEO-EU'], - ), - Endpoint( - name='lhr', - type=nested, - target_resource_id=profiles[1].id, - geo_mapping=['GB', 'WORLD'], - ), - Endpoint( - name='sto', - type=nested, - target_resource_id=profiles[2].id, - geo_mapping=['SE'], - ), - ], - ))) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_pool_status(self): - # test that traffic managers are generated as expected for pool value - # statuses - provider = self._get_provider() - zone1 = Zone('unit.tests.', []) - record1 = Record.new(zone1, 'foo', data={ - 'type': 'CNAME', - 'ttl': 60, - 'value': 'default.unit.tests.', - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': 'one1.unit.tests.', 'status': 'up'}, - ], - }, - 'two': { - 'values': [ - {'value': 'two1.unit.tests.', 'status': 'down'}, - {'value': 'two2.unit.tests.'}, - ], - }, - }, - 'rules': [ - {'geos': ['AS'], 'pool': 'one'}, - {'pool': 'two'}, - ], - } - }) - zone1.add_record(record1) - zone2 = provider._process_desired_zone(zone1.copy()) - record2 = list(zone2.records)[0] - self.assertTrue( - record2.dynamic.pools['one'].data['values'][0]['status'], - 'obey' - ) - - record1.dynamic.pools['one'].data['values'][0]['status'] = 'down' - profiles = provider._generate_traffic_managers(record1) - self.assertEqual(len(profiles), 4) - self.assertEqual(profiles[0].endpoints[0].endpoint_status, 'Disabled') - self.assertEqual(profiles[1].endpoints[0].endpoint_status, 'Disabled') - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record1.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record1._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record1.dynamic._data(), record2.dynamic._data()) - - # _process_desired_zone shouldn't change anything when status value is - # supported - zone1 = Zone(zone.name, sub_zones=[]) - zone1.add_record(record1) - zone2 = provider._process_desired_zone(zone1.copy()) - record2 = list(zone2.records)[0] - self.assertTrue(record1.data, record2.data) - - # simple records should not get changed by _process_desired_zone - zone1 = Zone(zone.name, sub_zones=[]) - record1 = Record.new(zone1, 'foo', data={ - 'type': 'CNAME', - 'ttl': 86400, - 'value': 'one.unit.tests.', - }) - zone1.add_record(record1) - zone2 = provider._process_desired_zone(zone1.copy()) - record2 = list(zone2.records)[0] - self.assertTrue(record1.data, record2.data) - - def test_dynamic_A(self): - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - nested = 'Microsoft.Network/trafficManagerProfiles/nestedEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'A', - 'ttl': 60, - 'values': ['9.9.9.9'], - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': '11.11.11.11'}, - {'value': '12.12.12.12'}, - ], - 'fallback': 'two' - }, - 'two': { - 'values': [ - {'value': '2.2.2.2'}, - ], - }, - }, - 'rules': [ - {'geos': ['AF'], 'pool': 'one'}, - {'pool': 'two'}, - ], - } - }) - - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 4) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests-A-pool-one', - traffic_routing_method='Weighted', - dns_config=DnsConfig( - relative_name='foo--unit--tests-a-pool-one', ttl=record.ttl), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='one--11.11.11.11', - type=external, - target='11.11.11.11', - weight=1, - ), - Endpoint( - name='one--12.12.12.12', - type=external, - target='12.12.12.12', - weight=1, - ), - ], - ))) - self.assertTrue(_profile_is_match(profiles[1], Profile( - name='foo--unit--tests-A-rule-one', - traffic_routing_method='Priority', - dns_config=DnsConfig( - relative_name='foo--unit--tests-a-rule-one', ttl=record.ttl), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='one', - type=nested, - target_resource_id=profiles[0].id, - priority=1, - ), - Endpoint( - name='two', - type=external, - target='2.2.2.2', - priority=2, - ), - Endpoint( - name='--default--', - type=external, - target='9.9.9.9', - priority=3, - ), - ], - ))) - self.assertTrue(_profile_is_match(profiles[2], Profile( - name='foo--unit--tests-A-rule-two', - traffic_routing_method='Priority', - dns_config=DnsConfig( - relative_name='foo--unit--tests-a-rule-two', ttl=record.ttl), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='two', - type=external, - target='2.2.2.2', - priority=1, - ), - Endpoint( - name='--default--', - type=external, - target='9.9.9.9', - priority=2, - ), - ], - ))) - self.assertTrue(_profile_is_match(profiles[3], Profile( - name='foo--unit--tests-A', - traffic_routing_method='Geographic', - dns_config=DnsConfig( - relative_name='foo--unit--tests-a', ttl=record.ttl), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='one', - type=nested, - target_resource_id=profiles[1].id, - geo_mapping=['GEO-AF'], - ), - Endpoint( - name='two', - type=nested, - target_resource_id=profiles[2].id, - geo_mapping=['WORLD'], - ), - ], - ))) - - # test that the record and ATM profile gets created - tm_sync = provider._tm_client.profiles.create_or_update - create = provider._dns_client.record_sets.create_or_update - provider._apply_Create(Create(record)) - self.assertEqual(tm_sync.call_count, len(profiles) + 1) - create.assert_called_once() - - # test broken alias - azrecord = RecordSet( - ttl=60, target_resource=SubResource(id=None)) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord, lenient=True) - self.assertEqual(record2.values, []) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - provider._populate_traffic_managers() - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_dynamic_AAAA(self): - provider = self._get_provider() - external = 'Microsoft.Network/trafficManagerProfiles/externalEndpoints' - - record = Record.new(zone, 'foo', data={ - 'type': 'AAAA', - 'ttl': 60, - 'values': ['1::1', '2::2'], - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': '1::1'}, - {'value': '2::2'}, - ], - }, - }, - 'rules': [ - {'pool': 'one'}, - ], - } - }) - profiles = provider._generate_traffic_managers(record) - - self.assertEqual(len(profiles), 1) - self.assertTrue(_profile_is_match(profiles[0], Profile( - name='foo--unit--tests-AAAA', - traffic_routing_method='Weighted', - dns_config=DnsConfig( - relative_name='foo--unit--tests-aaaa', ttl=record.ttl), - monitor_config=_get_monitor(record), - endpoints=[ - Endpoint( - name='one--1--1--default--', - type=external, - target='1::1', - weight=1, - ), - Endpoint( - name='one--2--2--default--', - type=external, - target='2::2', - weight=1, - ), - ], - ))) - - # test that the record and ATM profile gets created - tm_sync = provider._tm_client.profiles.create_or_update - create = provider._dns_client.record_sets.create_or_update - provider._apply_Create(Create(record)) - # A dynamic record can only have 1 profile - tm_sync.assert_called_once() - create.assert_called_once() - - # test broken alias - azrecord = RecordSet( - ttl=60, target_resource=SubResource(id=None)) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord, lenient=True) - self.assertEqual(record2.values, []) - - # test that same record gets populated back from traffic managers - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = profiles - azrecord = RecordSet( - ttl=60, - target_resource=SubResource(id=profiles[-1].id), - ) - azrecord.name = record.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record._type}' - record2 = provider._populate_record(zone, azrecord) - self.assertEqual(record2.dynamic._data(), record.dynamic._data()) - - # test that extra changes doesn't show any changes - desired = Zone(zone.name, sub_zones=[]) - desired.add_record(record) - changes = provider._extra_changes(zone, desired, []) - self.assertEqual(len(changes), 0) - - def test_sync_traffic_managers(self): - provider, zone, record = self._get_dynamic_package() - provider._populate_traffic_managers() - - tm_sync = provider._tm_client.profiles.create_or_update - - prefix = 'foo--unit--tests' - expected_seen = { - prefix, f'{prefix}-pool-two', f'{prefix}-rule-one', - f'{prefix}-rule-two', - } - - # test no change - profiles = provider._generate_traffic_managers(record) - seen = provider._sync_traffic_managers(profiles) - self.assertEqual(seen, expected_seen) - tm_sync.assert_not_called() - - # test that changing weight causes update API call - dynamic = record.dynamic._data() - dynamic['pools']['two']['values'][0]['weight'] = 14 - data = { - 'type': 'CNAME', - 'ttl': record.ttl, - 'value': record.value, - 'dynamic': dynamic, - 'octodns': record._octodns, - } - new_record = Record.new(zone, record.name, data) - tm_sync.reset_mock() - profiles = provider._generate_traffic_managers(new_record) - seen2 = provider._sync_traffic_managers(profiles) - self.assertEqual(seen2, expected_seen) - tm_sync.assert_called_once() - - # test that new profile was successfully inserted in cache - new_profile = provider._get_tm_profile_by_name(f'{prefix}-pool-two') - self.assertEqual(new_profile.endpoints[0].weight, 14) - - def test_sync_traffic_managers_duplicate(self): - provider, zone, record = self._get_dynamic_package() - tm_sync = provider._tm_client.profiles.create_or_update - - # change and duplicate profiles - profile = self._get_tm_profiles(provider)[0] - profile.name = 'changing_this_to_trigger_sync' - provider._sync_traffic_managers([profile, profile]) - - # it should only be called once for duplicate profiles - tm_sync.assert_called_once() - - def test_find_traffic_managers(self): - provider, zone, record = self._get_dynamic_package() - - # insert a non-matching profile - sample_profile = self._get_tm_profiles(provider)[0] - # dummy record for generating suffix - record2 = Record.new(zone, record.name + '2', data={ - 'type': record._type, - 'ttl': record.ttl, - 'value': record.value, - }) - prefix2 = _root_traffic_manager_name(record2) - tm_id = provider._profile_name_to_id - extra_profile = Profile( - id=tm_id(f'{prefix2}-pool-random'), - name=f'{prefix2}-pool-random', - traffic_routing_method='Weighted', - dns_config=sample_profile.dns_config, - monitor_config=sample_profile.monitor_config, - endpoints=sample_profile.endpoints, - ) - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value.append(extra_profile) - provider._populate_traffic_managers() - - # implicitly asserts that non-matching profile is not included - prefix = _root_traffic_manager_name(record) - self.assertEqual(provider._find_traffic_managers(record), { - prefix, f'{prefix}-pool-two', f'{prefix}-rule-one', - f'{prefix}-rule-two', - }) - - def test_traffic_manager_gc(self): - provider, zone, record = self._get_dynamic_package() - provider._populate_traffic_managers() - - profiles = provider._find_traffic_managers(record) - profile_delete_mock = provider._tm_client.profiles.delete - - provider._traffic_managers_gc(record, profiles) - profile_delete_mock.assert_not_called() - - profile_delete_mock.reset_mock() - remove = list(profiles)[3] - profiles.discard(remove) - - provider._traffic_managers_gc(record, profiles) - profile_delete_mock.assert_has_calls( - [call(provider._resource_group, remove)] - ) - - def test_apply(self): - provider = self._get_provider() - - expected_n = len(octo_records) - half = int(expected_n / 2) - changes = [Create(r) for r in octo_records[:half]] + \ - [Update(r, r) for r in octo_records[half:]] - deletes = [Delete(r) for r in octo_records] - - self.assertEquals(expected_n, provider.apply(Plan(None, zone, - changes, True))) - self.assertEquals(expected_n, provider.apply(Plan(zone, zone, - deletes, True))) - - def test_apply_create_dynamic(self): - provider = self._get_provider() - - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = [] - - tm_sync = provider._tm_client.profiles.create_or_update - - record = self._get_dynamic_record(zone) - - profiles = self._get_tm_profiles(provider) - - provider._apply_Create(Create(record)) - # create was called as many times as number of profiles required for - # the dynamic record - self.assertEqual(tm_sync.call_count, len(profiles)) - - create = provider._dns_client.record_sets.create_or_update - create.assert_called_once() - - def test_apply_update_dynamic(self): - # existing is simple, new is dynamic - provider = self._get_provider() - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value = [] - profiles = self._get_tm_profiles(provider) - dynamic_record = self._get_dynamic_record(zone) - simple_record = Record.new(zone, dynamic_record.name, data={ - 'type': 'CNAME', - 'ttl': 3600, - 'value': 'cname.unit.tests.', - }) - change = Update(simple_record, dynamic_record) - provider._apply_Update(change) - tm_sync, dns_update, tm_delete = ( - provider._tm_client.profiles.create_or_update, - provider._dns_client.record_sets.create_or_update, - provider._tm_client.profiles.delete - ) - self.assertEqual(tm_sync.call_count, len(profiles)) - dns_update.assert_called_once() - tm_delete.assert_not_called() - - # existing is dynamic, new is simple - provider, existing, dynamic_record = self._get_dynamic_package() - profiles = self._get_tm_profiles(provider) - change = Update(dynamic_record, simple_record) - provider._apply_Update(change) - tm_sync, dns_update, tm_delete = ( - provider._tm_client.profiles.create_or_update, - provider._dns_client.record_sets.create_or_update, - provider._tm_client.profiles.delete - ) - tm_sync.assert_not_called() - dns_update.assert_called_once() - self.assertEqual(tm_delete.call_count, len(profiles)) - - # both are dynamic, healthcheck port is changed - provider, existing, dynamic_record = self._get_dynamic_package() - profiles = self._get_tm_profiles(provider) - dynamic_record2 = self._get_dynamic_record(existing) - dynamic_record2._octodns['healthcheck']['port'] += 1 - change = Update(dynamic_record, dynamic_record2) - provider._apply_Update(change) - tm_sync, dns_update, tm_delete = ( - provider._tm_client.profiles.create_or_update, - provider._dns_client.record_sets.create_or_update, - provider._tm_client.profiles.delete - ) - self.assertEqual(tm_sync.call_count, len(profiles)) - dns_update.assert_not_called() - tm_delete.assert_not_called() - - # both are dynamic, extra profile should be deleted - provider, existing, dynamic_record = self._get_dynamic_package() - sample_profile = self._get_tm_profiles(provider)[0] - tm_id = provider._profile_name_to_id - root_profile_name = _root_traffic_manager_name(dynamic_record) - extra_profile = Profile( - id=tm_id(f'{root_profile_name}-pool-random'), - name=f'{root_profile_name}-pool-random', - traffic_routing_method='Weighted', - dns_config=sample_profile.dns_config, - monitor_config=sample_profile.monitor_config, - endpoints=sample_profile.endpoints, - ) - tm_list = provider._tm_client.profiles.list_by_resource_group - tm_list.return_value.append(extra_profile) - change = Update(dynamic_record, dynamic_record) - provider._apply_Update(change) - tm_sync, dns_update, tm_delete = ( - provider._tm_client.profiles.create_or_update, - provider._dns_client.record_sets.create_or_update, - provider._tm_client.profiles.delete - ) - tm_sync.assert_not_called() - dns_update.assert_not_called() - tm_delete.assert_called_once() - - # both are dynamic but alias is broken - provider, existing, record1 = self._get_dynamic_package() - azrecord = RecordSet( - ttl=record1.ttl, target_resource=SubResource(id=None)) - azrecord.name = record1.name or '@' - azrecord.type = f'Microsoft.Network/dnszones/{record1._type}' - - record2 = provider._populate_record(zone, azrecord, lenient=True) - self.assertIsNone(record2.value) - - change = Update(record2, record1) - provider._apply_Update(change) - tm_sync, dns_update, tm_delete = ( - provider._tm_client.profiles.create_or_update, - provider._dns_client.record_sets.create_or_update, - provider._tm_client.profiles.delete - ) - tm_sync.assert_not_called() - dns_update.assert_called_once() - tm_delete.assert_not_called() - - def test_apply_update_dynamic_A(self): - # existing is simple, new is dynamic - provider = self._get_provider() - simple_record = Record.new(zone, 'foo', data={ - 'type': 'A', - 'ttl': 3600, - 'values': ['1.1.1.1', '2.2.2.2'], - }) - dynamic_record = Record.new(zone, simple_record.name, data={ - 'type': 'A', - 'ttl': 60, - 'values': ['1.1.1.1'], - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': '8.8.8.8'}, - {'value': '4.4.4.4'}, - ], - 'fallback': 'two', - }, - 'two': { - 'values': [{'value': '9.9.9.9'}], - }, - }, - 'rules': [ - {'geos': ['AF'], 'pool': 'two'}, - {'pool': 'one'}, - ], - } - }) - num_tms = len(provider._generate_traffic_managers(dynamic_record)) - change = Update(simple_record, dynamic_record) - provider._apply_Update(change) - tm_sync, dns_update, tm_delete = ( - provider._tm_client.profiles.create_or_update, - provider._dns_client.record_sets.create_or_update, - provider._tm_client.profiles.delete - ) - # sync is called once for each profile, plus 1 at the end for nested - # endpoints to workaround A/AAAA nesting limitation in Azure - self.assertEqual(tm_sync.call_count, num_tms + 1) - dns_update.assert_called_once() - tm_delete.assert_not_called() - - # both are dynamic, healthcheck port is changed to trigger sync on - # all profiles - provider = self._get_provider() - dynamic_record2 = Record.new(zone, dynamic_record.name, data={ - 'type': dynamic_record._type, - 'ttl': 300, - 'values': dynamic_record.values, - 'dynamic': dynamic_record.dynamic._data(), - 'octodns': { - 'healthcheck': {'port': 4433}, - } - }) - change = Update(dynamic_record, dynamic_record2) - provider._apply_Update(change) - tm_sync, dns_update, tm_delete = ( - provider._tm_client.profiles.create_or_update, - provider._dns_client.record_sets.create_or_update, - provider._tm_client.profiles.delete - ) - # sync is called once for each profile, extra call at the end is not - # needed when existing dynamic record is already aliased to its root - # profile - self.assertEqual(tm_sync.call_count, num_tms) - dns_update.assert_not_called() - tm_delete.assert_not_called() - - def test_apply_update_dynamic_A_singluar(self): - # existing is simple, new is dynamic that needs only one profile - provider = self._get_provider() - simple_record = Record.new(zone, 'foo', data={ - 'type': 'A', - 'ttl': 3600, - 'values': ['1.1.1.1', '2.2.2.2'], - }) - dynamic_record = Record.new(zone, simple_record.name, data={ - 'type': 'A', - 'ttl': 60, - 'values': ['1.1.1.1'], - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': '8.8.8.8'}, - {'value': '1.1.1.1'}, - ], - }, - }, - 'rules': [ - {'pool': 'one'}, - ], - } - }) - num_tms = len(provider._generate_traffic_managers(dynamic_record)) - self.assertEqual(num_tms, 1) - change = Update(simple_record, dynamic_record) - provider._apply_Update(change) - tm_sync, dns_update, tm_delete = ( - provider._tm_client.profiles.create_or_update, - provider._dns_client.record_sets.create_or_update, - provider._tm_client.profiles.delete - ) - self.assertEqual(tm_sync.call_count, num_tms) - dns_update.assert_called_once() - tm_delete.assert_not_called() - - def test_apply_delete_dynamic(self): - provider, existing, record = self._get_dynamic_package() - provider._populate_traffic_managers() - profiles = self._get_tm_profiles(provider) - change = Delete(record) - provider._apply_Delete(change) - dns_delete, tm_delete = ( - provider._dns_client.record_sets.delete, - provider._tm_client.profiles.delete - ) - dns_delete.assert_called_once() - self.assertEqual(tm_delete.call_count, len(profiles)) - - def test_create_zone(self): - provider = self._get_provider() - - changes = [] - for i in octo_records: - changes.append(Create(i)) - desired = Zone('unit2.test.', []) - - err_msg = 'The Resource \'Microsoft.Network/dnszones/unit2.test\' ' - err_msg += 'under resource group \'mock_rg\' was not found.' - _get = provider._dns_client.zones.get - _get.side_effect = CloudError(Mock(status=404), err_msg) - - expected_n = len(octo_records) - self.assertEquals(expected_n, provider.apply(Plan(None, desired, - changes, True))) - - def test_check_zone_no_create(self): - provider = self._get_provider() - - rs = [] - recordSet = RecordSet(a_records=[ARecord(ipv4_address='1.1.1.1')]) - recordSet.name, recordSet.ttl, recordSet.type = 'a1', 0, 'A' - rs.append(recordSet) - recordSet = RecordSet(a_records=[ARecord(ipv4_address='1.1.1.1'), - ARecord(ipv4_address='2.2.2.2')]) - recordSet.name, recordSet.ttl, recordSet.type = 'a2', 1, 'A' - rs.append(recordSet) - - record_list = provider._dns_client.record_sets.list_by_dns_zone - record_list.return_value = rs - - err_msg = 'The Resource \'Microsoft.Network/dnszones/unit3.test\' ' - err_msg += 'under resource group \'mock_rg\' was not found.' - _get = provider._dns_client.zones.get - _get.side_effect = CloudError(Mock(status=404), err_msg) - - exists = provider.populate(Zone('unit3.test.', [])) - self.assertFalse(exists) - - self.assertEquals(len(zone.records), 0) + def test_missing(self): + with self.assertRaises(ModuleNotFoundError): + from octodns.provider.azuredns import AzureProvider + AzureProvider diff --git a/tests/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py index 858389f..1e3df1e 100644 --- a/tests/test_octodns_provider_gandi.py +++ b/tests/test_octodns_provider_gandi.py @@ -5,372 +5,17 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from mock import Mock, call -from os.path import dirname, join -from requests import HTTPError -from requests_mock import ANY, mock as requests_mock from unittest import TestCase -from octodns.record import Record -from octodns.provider.gandi import GandiProvider, GandiClientBadRequest, \ - GandiClientUnauthorized, GandiClientForbidden, GandiClientNotFound, \ - GandiClientUnknownDomainName -from octodns.provider.yaml import YamlProvider -from octodns.zone import Zone +# Just for coverage +import octodns.provider.fastdns +# Quell warnings +octodns.provider.fastdns -class TestGandiProvider(TestCase): - expected = Zone('unit.tests.', []) - source = YamlProvider('test', join(dirname(__file__), 'config')) - source.populate(expected) +class TestGandiShim(TestCase): - # We remove this record from the test zone as Gandi API reject it - # (rightfully). - expected._remove_record(Record.new(expected, 'sub', { - 'ttl': 1800, - 'type': 'NS', - 'values': [ - '6.2.3.4.', - '7.2.3.4.' - ] - })) - - def test_populate(self): - - provider = GandiProvider('test_id', 'token') - - # 400 - Bad Request. - with requests_mock() as mock: - mock.get(ANY, status_code=400, - text='{"status": "error", "errors": [{"location": ' - '"body", "name": "items", "description": ' - '"\'6.2.3.4.\': invalid hostname (param: ' - '{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, ' - '\'rrset_name\': u\'sub\', \'rrset_values\': ' - '[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}, {"location": ' - '"body", "name": "items", "description": ' - '"\'7.2.3.4.\': invalid hostname (param: ' - '{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, ' - '\'rrset_name\': u\'sub\', \'rrset_values\': ' - '[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}]}') - - with self.assertRaises(GandiClientBadRequest) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertIn('"status": "error"', str(ctx.exception)) - - # 401 - Unauthorized. - with requests_mock() as mock: - mock.get(ANY, status_code=401, - text='{"code":401,"message":"The server could not verify ' - 'that you authorized to access the document you ' - 'requested. Either you supplied the wrong ' - 'credentials (e.g., bad api key), or your access ' - 'token has expired","object":"HTTPUnauthorized",' - '"cause":"Unauthorized"}') - - with self.assertRaises(GandiClientUnauthorized) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertIn('"cause":"Unauthorized"', str(ctx.exception)) - - # 403 - Forbidden. - with requests_mock() as mock: - mock.get(ANY, status_code=403, - text='{"code":403,"message":"Access was denied to this ' - 'resource.","object":"HTTPForbidden","cause":' - '"Forbidden"}') - - with self.assertRaises(GandiClientForbidden) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertIn('"cause":"Forbidden"', str(ctx.exception)) - - # 404 - Not Found. - with requests_mock() as mock: - mock.get(ANY, status_code=404, - text='{"code": 404, "message": "The resource could not ' - 'be found.", "object": "HTTPNotFound", "cause": ' - '"Not Found"}') - - with self.assertRaises(GandiClientNotFound) as ctx: - zone = Zone('unit.tests.', []) - provider._client.zone(zone) - self.assertIn('"cause": "Not Found"', str(ctx.exception)) - - # General error - with requests_mock() as mock: - mock.get(ANY, status_code=502, text='Things caught fire') - - with self.assertRaises(HTTPError) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(502, ctx.exception.response.status_code) - - # No diffs == no changes - with requests_mock() as mock: - base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ - '/records' - with open('tests/fixtures/gandi-no-changes.json') as fh: - mock.get(base, text=fh.read()) - - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(16, len(zone.records)) - changes = self.expected.changes(zone, provider) - self.assertEquals(0, len(changes)) - - del provider._zone_records[zone.name] - - # Default Gandi zone file. - with requests_mock() as mock: - base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ - '/records' - with open('tests/fixtures/gandi-records.json') as fh: - mock.get(base, text=fh.read()) - - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(11, len(zone.records)) - changes = self.expected.changes(zone, provider) - self.assertEquals(24, len(changes)) - - # 2nd populate makes no network calls/all from cache - again = Zone('unit.tests.', []) - provider.populate(again) - self.assertEquals(11, len(again.records)) - - # bust the cache - del provider._zone_records[zone.name] - - def test_apply(self): - provider = GandiProvider('test_id', 'token') - - # Zone does not exists but can be created. - with requests_mock() as mock: - mock.get(ANY, status_code=404, - text='{"code": 404, "message": "The resource could not ' - 'be found.", "object": "HTTPNotFound", "cause": ' - '"Not Found"}') - mock.post(ANY, status_code=201, - text='{"message": "Domain Created"}') - - plan = provider.plan(self.expected) - provider.apply(plan) - - # Zone does not exists and can't be created. - with requests_mock() as mock: - mock.get(ANY, status_code=404, - text='{"code": 404, "message": "The resource could not ' - 'be found.", "object": "HTTPNotFound", "cause": ' - '"Not Found"}') - mock.post(ANY, status_code=404, - text='{"code": 404, "message": "The resource could not ' - 'be found.", "object": "HTTPNotFound", "cause": ' - '"Not Found"}') - - with self.assertRaises((GandiClientNotFound, - GandiClientUnknownDomainName)) as ctx: - plan = provider.plan(self.expected) - provider.apply(plan) - self.assertIn('This domain is not registered at Gandi.', - str(ctx.exception)) - - resp = Mock() - resp.json = Mock() - provider._client._request = Mock(return_value=resp) - - with open('tests/fixtures/gandi-zone.json') as fh: - zone = fh.read() - - # non-existent domain - resp.json.side_effect = [ - GandiClientNotFound(resp), # no zone in populate - GandiClientNotFound(resp), # no domain during apply - zone - ] - plan = provider.plan(self.expected) - - # No root NS, no ignored, no excluded, no LOC - n = len(self.expected.records) - 6 - self.assertEquals(n, len(plan.changes)) - self.assertEquals(n, provider.apply(plan)) - self.assertFalse(plan.exists) - - provider._client._request.assert_has_calls([ - call('GET', '/livedns/domains/unit.tests/records'), - call('GET', '/livedns/domains/unit.tests'), - call('POST', '/livedns/domains', data={ - 'fqdn': 'unit.tests', - 'zone': {} - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'www.sub', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['2.2.3.6'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'www', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['2.2.3.6'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'txt', - 'rrset_ttl': 600, - 'rrset_type': 'TXT', - 'rrset_values': [ - 'Bah bah black sheep', - 'have you any wool.', - 'v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string' - '+with+numb3rs' - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'spf', - 'rrset_ttl': 600, - 'rrset_type': 'SPF', - 'rrset_values': ['v=spf1 ip4:192.168.0.1/16-all'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'ptr', - 'rrset_ttl': 300, - 'rrset_type': 'PTR', - 'rrset_values': ['foo.bar.com.'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'mx', - 'rrset_ttl': 300, - 'rrset_type': 'MX', - 'rrset_values': [ - '10 smtp-4.unit.tests.', - '20 smtp-2.unit.tests.', - '30 smtp-3.unit.tests.', - '40 smtp-1.unit.tests.' - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'excluded', - 'rrset_ttl': 3600, - 'rrset_type': 'CNAME', - 'rrset_values': ['unit.tests.'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'dname', - 'rrset_ttl': 300, - 'rrset_type': 'DNAME', - 'rrset_values': ['unit.tests.'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'cname', - 'rrset_ttl': 300, - 'rrset_type': 'CNAME', - 'rrset_values': ['unit.tests.'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'aaaa', - 'rrset_ttl': 600, - 'rrset_type': 'AAAA', - 'rrset_values': ['2601:644:500:e210:62f8:1dff:feb8:947a'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '_srv._tcp', - 'rrset_ttl': 600, - 'rrset_type': 'SRV', - 'rrset_values': [ - '10 20 30 foo-1.unit.tests.', - '12 20 30 foo-2.unit.tests.' - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '_pop3._tcp', - 'rrset_ttl': 600, - 'rrset_type': 'SRV', - 'rrset_values': [ - '0 0 0 .', - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '_imap._tcp', - 'rrset_ttl': 600, - 'rrset_type': 'SRV', - 'rrset_values': [ - '0 0 0 .', - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '@', - 'rrset_ttl': 3600, - 'rrset_type': 'SSHFP', - 'rrset_values': [ - '1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49', - '1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73' - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '@', - 'rrset_ttl': 3600, - 'rrset_type': 'CAA', - 'rrset_values': ['0 issue "ca.unit.tests"'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '@', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['1.2.3.4', '1.2.3.5'] - }) - ]) - # expected number of total calls - self.assertEquals(19, provider._client._request.call_count) - - provider._client._request.reset_mock() - - # delete 1 and update 1 - provider._client.zone_records = Mock(return_value=[ - { - 'rrset_name': 'www', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['1.2.3.4'] - }, - { - 'rrset_name': 'www', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['2.2.3.4'] - }, - { - 'rrset_name': 'ttl', - 'rrset_ttl': 600, - 'rrset_type': 'A', - 'rrset_values': ['3.2.3.4'] - } - ]) - - # Domain exists, we don't care about return - resp.json.side_effect = ['{}'] - - wanted = Zone('unit.tests.', []) - wanted.add_record(Record.new(wanted, 'ttl', { - 'ttl': 300, - 'type': 'A', - 'value': '3.2.3.4' - })) - - plan = provider.plan(wanted) - self.assertTrue(plan.exists) - self.assertEquals(2, len(plan.changes)) - self.assertEquals(2, provider.apply(plan)) - - # recreate for update, and deletes for the 2 parts of the other - provider._client._request.assert_has_calls([ - call('DELETE', '/livedns/domains/unit.tests/records/www/A'), - call('DELETE', '/livedns/domains/unit.tests/records/ttl/A'), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'ttl', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['3.2.3.4'] - }) - ], any_order=True) + def test_missing(self): + with self.assertRaises(ModuleNotFoundError): + from octodns.provider.gandi import GandiProvider + GandiProvider