| @ -0,0 +1,68 @@ | |||
| ## v0.8.7 - 2017-09-29 - OVH support | |||
| Adds an OVH provider. | |||
| ## v0.8.6 - 2017-09-06 - CAA record type, | |||
| Misc fixes and improvments. | |||
| * Azure TXT record fix | |||
| * PowerDNS api support for https | |||
| * Configurable Route53 max retries and max-attempts | |||
| * Improved key ordering error message | |||
| ## v0.8.5 - 2017-07-21 - Azure, NS1 escaping, & large zones | |||
| Relatively small delta this go around. No major themes or anything, just steady | |||
| progress. | |||
| * AzureProvider added thanks to work by | |||
| [Heesu Hwang](https://github.com/h-hwang). | |||
| * Fixed some escaping issues with NS1 TXT and SPF records that were tracked down | |||
| with the help of [Blake Stoddard](https://github.com/blakestoddard). | |||
| * Some tweaks were made to Zone.records to vastly improve handling of zones with | |||
| very large numbers of records, no more O(N^2). | |||
| ## v0.8.4 - 2017-06-28 - It's been too long | |||
| Lots of updates based on our internal use, needs, and feedback & suggestions | |||
| from our OSS users. There's too much to list out since the previous release was | |||
| cut, but I'll try to cover the highlights/important bits and promise to do | |||
| better in the future :fingers_crossed: | |||
| #### Major: | |||
| * Complete rework of record validation with lenient mode support added to | |||
| octodns-dump so that data with validation problems can be dumped to config | |||
| files as a starting point. octoDNS now also ignores validation errors when | |||
| pulling the current state from a provider before planning changes. In both | |||
| cases this is best effort. | |||
| * Naming of record keys are based on RFC-1035 and friends, previous names have | |||
| been kept for backwards compatibility until the 1.0 release. | |||
| * Provider record type support is now explicit, i.e. opt-in, rather than | |||
| opt-out. This prevents bugs/oversights in record handling where providers | |||
| don't support (new) record types and didn't correctly ignore them. | |||
| * ALIAS support for DNSimple, Dyn, NS1, PowerDNS | |||
| * Ignored record support added, `octodns:\n ignored: True` | |||
| * Ns1Provider added | |||
| #### Miscellaneous | |||
| * Use a 3rd party lib for nautrual sorting of keys, rather than my old | |||
| implementation. Sorting can be disabled in the YamlProvider with | |||
| `enforce_order: False`. | |||
| * Semi-colon/escaping fixes and improvements. | |||
| * Meta record support, `TXT octodns-meta.<zone>`. For now just | |||
| `provider=<provider-id>`. Optionally turned on with `include_meta` manager | |||
| config val. | |||
| * Validations check for CNAMEs co-existing with other records and error out if | |||
| found. Was a common mistaken/unknown issue and this surfaces the problem | |||
| early. | |||
| * Sizeable refactor in the way Route53 record translation works to make it | |||
| cleaner/less hacky | |||
| * Lots of docs type-o fixes | |||
| * Fixed some pretty major bugs in DnsimpleProvider | |||
| * Relax UnsafePlan checks a bit, more to come here | |||
| * Set User-Agent header on Dyn health checks | |||
| ## v0.8.0 - 2017-03-14 - First public release | |||
| @ -1,8 +1,6 @@ | |||
| ''' | |||
| OctoDNS: DNS as code - Tools for managing DNS across multiple providers | |||
| ''' | |||
| 'OctoDNS: DNS as code - Tools for managing DNS across multiple providers' | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| __VERSION__ = '0.8.0' | |||
| __VERSION__ = '0.8.7' | |||
| @ -0,0 +1,443 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from azure.common.credentials import ServicePrincipalCredentials | |||
| from azure.mgmt.dns import DnsManagementClient | |||
| from msrestazure.azure_exceptions import CloudError | |||
| from azure.mgmt.dns.models import ARecord, AaaaRecord, CnameRecord, MxRecord, \ | |||
| SrvRecord, NsRecord, PtrRecord, TxtRecord, Zone | |||
| import logging | |||
| from functools import reduce | |||
| from ..record import Record | |||
| from .base import BaseProvider | |||
| class _AzureRecord(object): | |||
| '''Wrapper for OctoDNS record for AzureProvider to make dns_client calls. | |||
| azuredns.py: | |||
| class: octodns.provider.azuredns._AzureRecord | |||
| An _AzureRecord is easily accessible to Azure DNS Management library | |||
| functions and is used to wrap all relevant data to create a record in | |||
| Azure. | |||
| ''' | |||
| TYPE_MAP = { | |||
| 'A': ARecord, | |||
| 'AAAA': AaaaRecord, | |||
| 'CNAME': CnameRecord, | |||
| 'MX': MxRecord, | |||
| 'SRV': SrvRecord, | |||
| 'NS': NsRecord, | |||
| 'PTR': PtrRecord, | |||
| 'TXT': TxtRecord | |||
| } | |||
| def __init__(self, resource_group, record, delete=False): | |||
| '''Contructor for _AzureRecord. | |||
| Notes on Azure records: An Azure record set has the form | |||
| RecordSet(name=<...>, type=<...>, arecords=[...], aaaa_records, ..) | |||
| When constructing an azure record as done in self._apply_Create, | |||
| the argument parameters for an A record would be | |||
| parameters={'ttl': <int>, 'arecords': [ARecord(<str ip>),]}. | |||
| As another example for CNAME record: | |||
| parameters={'ttl': <int>, 'cname_record': CnameRecord(<str>)}. | |||
| Below, key_name and class_name are the dictionary key and Azure | |||
| Record class respectively. | |||
| :param resource_group: The name of resource group in Azure | |||
| :type resource_group: str | |||
| :param record: An OctoDNS record | |||
| :type record: ..record.Record | |||
| :param delete: If true, omit data parsing; not needed to delete | |||
| :type delete: bool | |||
| :type return: _AzureRecord | |||
| ''' | |||
| self.resource_group = resource_group | |||
| self.zone_name = record.zone.name[:len(record.zone.name) - 1] | |||
| self.relative_record_set_name = record.name or '@' | |||
| self.record_type = record._type | |||
| if delete: | |||
| return | |||
| # Refer to function docstring for key_name and class_name. | |||
| format_u_s = '' if record._type == 'A' else '_' | |||
| key_name = '{}{}records'.format(self.record_type, format_u_s).lower() | |||
| if record._type == 'CNAME': | |||
| key_name = key_name[:len(key_name) - 1] | |||
| azure_class = self.TYPE_MAP[self.record_type] | |||
| self.params = getattr(self, '_params_for_{}'.format(record._type)) | |||
| self.params = self.params(record.data, key_name, azure_class) | |||
| self.params['ttl'] = record.ttl | |||
| def _params(self, data, key_name, azure_class): | |||
| try: | |||
| values = data['values'] | |||
| except KeyError: | |||
| values = [data['value']] | |||
| return {key_name: [azure_class(v) for v in values]} | |||
| _params_for_A = _params | |||
| _params_for_AAAA = _params | |||
| _params_for_NS = _params | |||
| _params_for_PTR = _params | |||
| def _params_for_CNAME(self, data, key_name, azure_class): | |||
| return {key_name: azure_class(data['value'])} | |||
| def _params_for_MX(self, data, key_name, azure_class): | |||
| params = [] | |||
| if 'values' in data: | |||
| for vals in data['values']: | |||
| params.append(azure_class(vals['preference'], | |||
| vals['exchange'])) | |||
| else: # Else there is a singular data point keyed by 'value'. | |||
| params.append(azure_class(data['value']['preference'], | |||
| data['value']['exchange'])) | |||
| return {key_name: params} | |||
| def _params_for_SRV(self, data, key_name, azure_class): | |||
| params = [] | |||
| if 'values' in data: | |||
| for vals in data['values']: | |||
| params.append(azure_class(vals['priority'], | |||
| vals['weight'], | |||
| vals['port'], | |||
| vals['target'])) | |||
| else: # Else there is a singular data point keyed by 'value'. | |||
| params.append(azure_class(data['value']['priority'], | |||
| data['value']['weight'], | |||
| data['value']['port'], | |||
| data['value']['target'])) | |||
| return {key_name: params} | |||
| def _params_for_TXT(self, data, key_name, azure_class): | |||
| try: # API for TxtRecord has list of str, even for singleton | |||
| values = data['values'] | |||
| except KeyError: | |||
| values = [data['value']] | |||
| return {key_name: [azure_class([v]) for v in values]} | |||
| def _equals(self, b): | |||
| '''Checks whether two records are equal by comparing all fields. | |||
| :param b: Another _AzureRecord object | |||
| :type b: _AzureRecord | |||
| :type return: bool | |||
| ''' | |||
| def parse_dict(params): | |||
| vals = [] | |||
| for char in params: | |||
| if char != 'ttl': | |||
| list_records = params[char] | |||
| try: | |||
| for record in list_records: | |||
| vals.append(record.__dict__) | |||
| except: | |||
| vals.append(list_records.__dict__) | |||
| vals.sort() | |||
| return vals | |||
| return (self.resource_group == b.resource_group) & \ | |||
| (self.zone_name == b.zone_name) & \ | |||
| (self.record_type == b.record_type) & \ | |||
| (self.params['ttl'] == b.params['ttl']) & \ | |||
| (parse_dict(self.params) == parse_dict(b.params)) & \ | |||
| (self.relative_record_set_name == b.relative_record_set_name) | |||
| def __str__(self): | |||
| '''String representation of an _AzureRecord. | |||
| :type return: str | |||
| ''' | |||
| string = 'Zone: {}; '.format(self.zone_name) | |||
| string += 'Name: {}; '.format(self.relative_record_set_name) | |||
| string += 'Type: {}; '.format(self.record_type) | |||
| if not hasattr(self, 'params'): | |||
| return string | |||
| string += 'Ttl: {}; '.format(self.params['ttl']) | |||
| for char in self.params: | |||
| if char != 'ttl': | |||
| try: | |||
| for rec in self.params[char]: | |||
| string += 'Record: {}; '.format(rec.__dict__) | |||
| except: | |||
| string += 'Record: {}; '.format(self.params[char].__dict__) | |||
| return string | |||
| def _check_endswith_dot(string): | |||
| return string if string.endswith('.') else string + '.' | |||
| def _parse_azure_type(string): | |||
| '''Converts string representing an Azure RecordSet type to usual type. | |||
| :param string: the Azure type. eg: <Microsoft.Network/dnszones/A> | |||
| :type string: str | |||
| :type return: str | |||
| ''' | |||
| return string.split('/')[len(string.split('/')) - 1] | |||
| class AzureProvider(BaseProvider): | |||
| ''' | |||
| Azure DNS Provider | |||
| azuredns.py: | |||
| class: octodns.provider.azuredns.AzureProvider | |||
| # Current support of authentication of access to Azure services only | |||
| # includes using a Service Principal: | |||
| # https://docs.microsoft.com/en-us/azure/azure-resource-manager/ | |||
| # resource-group-create-service-principal-portal | |||
| # The Azure Active Directory Application ID (aka client ID): | |||
| client_id: | |||
| # Authentication Key Value: (note this should be secret) | |||
| key: | |||
| # Directory ID (aka tenant ID): | |||
| directory_id: | |||
| # Subscription ID: | |||
| sub_id: | |||
| # Resource Group name: | |||
| resource_group: | |||
| # All are required to authenticate. | |||
| Example config file with variables: | |||
| " | |||
| --- | |||
| providers: | |||
| config: | |||
| class: octodns.provider.yaml.YamlProvider | |||
| directory: ./config (example path to directory of zone files) | |||
| azuredns: | |||
| class: octodns.provider.azuredns.AzureProvider | |||
| client_id: env/AZURE_APPLICATION_ID | |||
| key: env/AZURE_AUTHENICATION_KEY | |||
| directory_id: env/AZURE_DIRECTORY_ID | |||
| sub_id: env/AZURE_SUBSCRIPTION_ID | |||
| resource_group: 'TestResource1' | |||
| zones: | |||
| example.com.: | |||
| sources: | |||
| - config | |||
| targets: | |||
| - azuredns | |||
| " | |||
| The first four variables above can be hidden in environment variables | |||
| and octoDNS will automatically search for them in the shell. It is | |||
| possible to also hard-code into the config file: eg, resource_group. | |||
| ''' | |||
| SUPPORTS_GEO = False | |||
| SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SRV', 'TXT')) | |||
| def __init__(self, id, client_id, key, directory_id, sub_id, | |||
| resource_group, *args, **kwargs): | |||
| self.log = logging.getLogger('AzureProvider[{}]'.format(id)) | |||
| self.log.debug('__init__: id=%s, client_id=%s, ' | |||
| 'key=***, directory_id:%s', id, client_id, directory_id) | |||
| super(AzureProvider, self).__init__(id, *args, **kwargs) | |||
| credentials = ServicePrincipalCredentials( | |||
| client_id, secret=key, tenant=directory_id | |||
| ) | |||
| self._dns_client = DnsManagementClient(credentials, sub_id) | |||
| self._resource_group = resource_group | |||
| self._azure_zones = set() | |||
| def _populate_zones(self): | |||
| self.log.debug('azure_zones: loading') | |||
| list_zones = self._dns_client.zones.list_by_resource_group | |||
| for zone in list_zones(self._resource_group): | |||
| self._azure_zones.add(zone.name) | |||
| def _check_zone(self, name, create=False): | |||
| '''Checks whether a zone specified in a source exist in Azure server. | |||
| Note that Azure zones omit end '.' eg: contoso.com vs contoso.com. | |||
| Returns the name if it exists. | |||
| :param name: Name of a zone to checks | |||
| :type name: str | |||
| :param create: If True, creates the zone of that name. | |||
| :type create: bool | |||
| :type return: str or None | |||
| ''' | |||
| self.log.debug('_check_zone: name=%s', name) | |||
| try: | |||
| if name in self._azure_zones: | |||
| return name | |||
| self._dns_client.zones.get(self._resource_group, name) | |||
| self._azure_zones.add(name) | |||
| return name | |||
| except CloudError as err: | |||
| msg = 'The Resource \'Microsoft.Network/dnszones/{}\''.format(name) | |||
| msg += ' under resource group \'{}\''.format(self._resource_group) | |||
| msg += ' was not found.' | |||
| if msg == err.message: | |||
| # Then the only error is that the zone doesn't currently exist | |||
| if create: | |||
| self.log.debug('_check_zone:no matching zone; creating %s', | |||
| name) | |||
| create_zone = self._dns_client.zones.create_or_update | |||
| create_zone(self._resource_group, name, Zone('global')) | |||
| return name | |||
| else: | |||
| return | |||
| raise | |||
| def populate(self, zone, target=False, lenient=False): | |||
| '''Required function of manager.py to collect records from zone. | |||
| Special notes for Azure. | |||
| Azure zone names omit final '.' | |||
| Azure root records names are represented by '@'. OctoDNS uses '' | |||
| Azure records created through online interface may have null values | |||
| (eg, no IP address for A record). | |||
| Azure online interface allows constructing records with null values | |||
| which are destroyed by _apply. | |||
| Specific quirks such as these are responsible for any non-obvious | |||
| parsing in this function and the functions '_params_for_*'. | |||
| :param zone: A dns zone | |||
| :type zone: octodns.zone.Zone | |||
| :param target: Checks if Azure is source or target of config. | |||
| Currently only supports as a target. Unused. | |||
| :type target: bool | |||
| :param lenient: Unused. Check octodns.manager for usage. | |||
| :type lenient: bool | |||
| :type return: void | |||
| ''' | |||
| self.log.debug('populate: name=%s', zone.name) | |||
| before = len(zone.records) | |||
| zone_name = zone.name[:len(zone.name) - 1] | |||
| self._populate_zones() | |||
| self._check_zone(zone_name) | |||
| _records = set() | |||
| records = self._dns_client.record_sets.list_by_dns_zone | |||
| if self._check_zone(zone_name): | |||
| for azrecord in records(self._resource_group, zone_name): | |||
| if _parse_azure_type(azrecord.type) in self.SUPPORTS: | |||
| _records.add(azrecord) | |||
| for azrecord in _records: | |||
| record_name = azrecord.name if azrecord.name != '@' else '' | |||
| typ = _parse_azure_type(azrecord.type) | |||
| data = getattr(self, '_data_for_{}'.format(typ)) | |||
| data = data(azrecord) | |||
| data['type'] = typ | |||
| data['ttl'] = azrecord.ttl | |||
| record = Record.new(zone, record_name, data, source=self) | |||
| zone.add_record(record) | |||
| self.log.info('populate: found %s records', len(zone.records) - before) | |||
| def _data_for_A(self, azrecord): | |||
| return {'values': [ar.ipv4_address for ar in azrecord.arecords]} | |||
| def _data_for_AAAA(self, azrecord): | |||
| return {'values': [ar.ipv6_address for ar in azrecord.aaaa_records]} | |||
| def _data_for_CNAME(self, azrecord): | |||
| '''Parsing data from Azure DNS Client record call | |||
| :param azrecord: a return of a call to list azure records | |||
| :type azrecord: azure.mgmt.dns.models.RecordSet | |||
| :type return: dict | |||
| CNAME and PTR both use the catch block to catch possible empty | |||
| records. Refer to population comment. | |||
| ''' | |||
| try: | |||
| return {'value': _check_endswith_dot(azrecord.cname_record.cname)} | |||
| except: | |||
| return {'value': '.'} | |||
| def _data_for_MX(self, azrecord): | |||
| return {'values': [{'preference': ar.preference, | |||
| 'exchange': ar.exchange} | |||
| for ar in azrecord.mx_records]} | |||
| def _data_for_NS(self, azrecord): | |||
| vals = [ar.nsdname for ar in azrecord.ns_records] | |||
| return {'values': [_check_endswith_dot(val) for val in vals]} | |||
| def _data_for_PTR(self, azrecord): | |||
| try: | |||
| ptrdname = azrecord.ptr_records[0].ptrdname | |||
| return {'value': _check_endswith_dot(ptrdname)} | |||
| except: | |||
| return {'value': '.'} | |||
| def _data_for_SRV(self, azrecord): | |||
| return {'values': [{'priority': ar.priority, 'weight': ar.weight, | |||
| 'port': ar.port, 'target': ar.target} | |||
| for ar in azrecord.srv_records]} | |||
| def _data_for_TXT(self, azrecord): | |||
| return {'values': [reduce((lambda a, b: a + b), ar.value) | |||
| for ar in azrecord.txt_records]} | |||
| def _apply_Create(self, change): | |||
| '''A record from change must be created. | |||
| :param change: a change object | |||
| :type change: octodns.record.Change | |||
| :type return: void | |||
| ''' | |||
| ar = _AzureRecord(self._resource_group, change.new) | |||
| create = self._dns_client.record_sets.create_or_update | |||
| create(resource_group_name=ar.resource_group, | |||
| zone_name=ar.zone_name, | |||
| relative_record_set_name=ar.relative_record_set_name, | |||
| record_type=ar.record_type, | |||
| parameters=ar.params) | |||
| self.log.debug('* Success Create/Update: {}'.format(ar)) | |||
| _apply_Update = _apply_Create | |||
| def _apply_Delete(self, change): | |||
| ar = _AzureRecord(self._resource_group, change.existing, delete=True) | |||
| delete = self._dns_client.record_sets.delete | |||
| delete(self._resource_group, ar.zone_name, ar.relative_record_set_name, | |||
| ar.record_type) | |||
| self.log.debug('* Success Delete: {}'.format(ar)) | |||
| def _apply(self, plan): | |||
| '''Required function of manager.py to actually apply a record change. | |||
| :param plan: Contains the zones and changes to be made | |||
| :type plan: octodns.provider.base.Plan | |||
| :type return: void | |||
| ''' | |||
| desired = plan.desired | |||
| changes = plan.changes | |||
| self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, | |||
| len(changes)) | |||
| azure_zone_name = desired.name[:len(desired.name) - 1] | |||
| self._check_zone(azure_zone_name, create=True) | |||
| for change in changes: | |||
| class_name = change.__class__.__name__ | |||
| getattr(self, '_apply_{}'.format(class_name))(change) | |||
| @ -0,0 +1,333 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| import shlex | |||
| import time | |||
| from logging import getLogger | |||
| from uuid import uuid4 | |||
| from google.cloud import dns | |||
| from .base import BaseProvider | |||
| from ..record import Record | |||
| class GoogleCloudProvider(BaseProvider): | |||
| """ | |||
| Google Cloud DNS provider | |||
| google_cloud: | |||
| class: octodns.provider.googlecloud.GoogleCloudProvider | |||
| # Credentials file for a service_account or other account can be | |||
| # specified with the GOOGLE_APPLICATION_CREDENTIALS environment | |||
| # variable. (https://console.cloud.google.com/apis/credentials) | |||
| # | |||
| # The project to work on (not required) | |||
| # project: foobar | |||
| # | |||
| # The File with the google credentials (not required). If used, the | |||
| # "project" parameter needs to be set, else it will fall back to the | |||
| # "default credentials" | |||
| # credentials_file: ~/google_cloud_credentials_file.json | |||
| # | |||
| """ | |||
| SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NAPTR', | |||
| 'NS', 'PTR', 'SPF', 'SRV', 'TXT')) | |||
| SUPPORTS_GEO = False | |||
| CHANGE_LOOP_WAIT = 5 | |||
| def __init__(self, id, project=None, credentials_file=None, | |||
| *args, **kwargs): | |||
| if credentials_file: | |||
| self.gcloud_client = dns.Client.from_service_account_json( | |||
| credentials_file, project=project) | |||
| else: | |||
| self.gcloud_client = dns.Client(project=project) | |||
| # Logger | |||
| self.log = getLogger('GoogleCloudProvider[{}]'.format(id)) | |||
| self.id = id | |||
| self._gcloud_zones = {} | |||
| super(GoogleCloudProvider, self).__init__(id, *args, **kwargs) | |||
| 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)) | |||
| # Get gcloud zone, or create one if none existed before. | |||
| if desired.name not in self.gcloud_zones: | |||
| gcloud_zone = self._create_gcloud_zone(desired.name) | |||
| else: | |||
| gcloud_zone = self.gcloud_zones.get(desired.name) | |||
| gcloud_changes = gcloud_zone.changes() | |||
| for change in changes: | |||
| class_name = change.__class__.__name__ | |||
| _rrset_func = getattr( | |||
| self, '_rrset_for_{}'.format(change.record._type)) | |||
| if class_name == 'Create': | |||
| gcloud_changes.add_record_set( | |||
| _rrset_func(gcloud_zone, change.record)) | |||
| elif class_name == 'Delete': | |||
| gcloud_changes.delete_record_set( | |||
| _rrset_func(gcloud_zone, change.record)) | |||
| elif class_name == 'Update': | |||
| gcloud_changes.delete_record_set( | |||
| _rrset_func(gcloud_zone, change.existing)) | |||
| gcloud_changes.add_record_set( | |||
| _rrset_func(gcloud_zone, change.new)) | |||
| else: | |||
| raise RuntimeError('Change type "{}" for change "{!s}" ' | |||
| 'is none of "Create", "Delete" or "Update' | |||
| .format(class_name, change)) | |||
| gcloud_changes.create() | |||
| for i in range(120): | |||
| gcloud_changes.reload() | |||
| # https://cloud.google.com/dns/api/v1/changes#resource | |||
| # status can be one of either "pending" or "done" | |||
| if gcloud_changes.status != 'pending': | |||
| break | |||
| self.log.debug("Waiting for changes to complete") | |||
| time.sleep(self.CHANGE_LOOP_WAIT) | |||
| if gcloud_changes.status != 'done': | |||
| raise RuntimeError("Timeout reached after {} seconds".format( | |||
| i * self.CHANGE_LOOP_WAIT)) | |||
| def _create_gcloud_zone(self, dns_name): | |||
| """Creates a google cloud ManagedZone with dns_name, and zone named | |||
| derived from it. calls .create() method and returns it. | |||
| :param dns_name: fqdn of zone to create | |||
| :type dns_name: str | |||
| :type return: new google.cloud.dns.ManagedZone | |||
| """ | |||
| # Zone name must begin with a letter, end with a letter or digit, | |||
| # and only contain lowercase letters, digits or dashes | |||
| zone_name = '{}-{}'.format( | |||
| dns_name[:-1].replace('.', '-'), uuid4().hex) | |||
| gcloud_zone = self.gcloud_client.zone( | |||
| name=zone_name, | |||
| dns_name=dns_name | |||
| ) | |||
| gcloud_zone.create(client=self.gcloud_client) | |||
| # add this new zone to the list of zones. | |||
| self._gcloud_zones[gcloud_zone.dns_name] = gcloud_zone | |||
| self.log.info("Created zone {}. Fqdn {}.".format(zone_name, dns_name)) | |||
| return gcloud_zone | |||
| def _get_gcloud_records(self, gcloud_zone, page_token=None): | |||
| """ Generator function which yields ResourceRecordSet for the managed | |||
| gcloud zone, until there are no more records to pull. | |||
| :param gcloud_zone: zone to pull records from | |||
| :type gcloud_zone: google.cloud.dns.ManagedZone | |||
| :param page_token: page token for the page to get | |||
| :return: a resource record set | |||
| :type return: google.cloud.dns.ResourceRecordSet | |||
| """ | |||
| gcloud_iterator = gcloud_zone.list_resource_record_sets( | |||
| page_token=page_token) | |||
| for gcloud_record in gcloud_iterator: | |||
| yield gcloud_record | |||
| # This is to get results which may be on a "paged" page. | |||
| # (if more than max_results) entries. | |||
| if gcloud_iterator.next_page_token: | |||
| for gcloud_record in self._get_gcloud_records( | |||
| gcloud_zone, gcloud_iterator.next_page_token): | |||
| # yield from is in python 3 only. | |||
| yield gcloud_record | |||
| def _get_cloud_zones(self, page_token=None): | |||
| """Load all ManagedZones into the self._gcloud_zones dict which is | |||
| mapped with the dns_name as key. | |||
| :return: void | |||
| """ | |||
| gcloud_zones = self.gcloud_client.list_zones(page_token=page_token) | |||
| for gcloud_zone in gcloud_zones: | |||
| self._gcloud_zones[gcloud_zone.dns_name] = gcloud_zone | |||
| if gcloud_zones.next_page_token: | |||
| self._get_cloud_zones(gcloud_zones.next_page_token) | |||
| @property | |||
| def gcloud_zones(self): | |||
| if not self._gcloud_zones: | |||
| self._get_cloud_zones() | |||
| return self._gcloud_zones | |||
| def populate(self, zone, target=False, lenient=False): | |||
| """Required function of manager.py to collect records from zone. | |||
| :param zone: A dns zone | |||
| :type zone: octodns.zone.Zone | |||
| :param 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, target=%s, lenient=%s', zone.name, | |||
| target, lenient) | |||
| before = len(zone.records) | |||
| gcloud_zone = self.gcloud_zones.get(zone.name) | |||
| if gcloud_zone: | |||
| for gcloud_record in self._get_gcloud_records(gcloud_zone): | |||
| if gcloud_record.record_type.upper() not in self.SUPPORTS: | |||
| continue | |||
| record_name = gcloud_record.name | |||
| if record_name.endswith(zone.name): | |||
| # google cloud always return fqdn. Make relative record | |||
| # here. "root" records will then get the '' record_name, | |||
| # which is also the way octodns likes it. | |||
| record_name = record_name[:-(len(zone.name) + 1)] | |||
| typ = gcloud_record.record_type.upper() | |||
| data = getattr(self, '_data_for_{}'.format(typ)) | |||
| data = data(gcloud_record) | |||
| data['type'] = typ | |||
| data['ttl'] = gcloud_record.ttl | |||
| self.log.debug('populate: adding record {} records: {!s}' | |||
| .format(record_name, data)) | |||
| record = Record.new(zone, record_name, data, source=self) | |||
| zone.add_record(record) | |||
| self.log.info('populate: found %s records', len(zone.records) - before) | |||
| def _data_for_A(self, gcloud_record): | |||
| return { | |||
| 'values': gcloud_record.rrdatas | |||
| } | |||
| _data_for_AAAA = _data_for_A | |||
| def _data_for_CAA(self, gcloud_record): | |||
| return { | |||
| 'values': [{ | |||
| 'flags': v[0], | |||
| 'tag': v[1], | |||
| 'value': v[2]} | |||
| for v in [shlex.split(g) for g in gcloud_record.rrdatas]]} | |||
| def _data_for_CNAME(self, gcloud_record): | |||
| return { | |||
| 'value': gcloud_record.rrdatas[0] | |||
| } | |||
| def _data_for_MX(self, gcloud_record): | |||
| return {'values': [{ | |||
| "preference": v[0], | |||
| "exchange": v[1]} | |||
| for v in [shlex.split(g) for g in gcloud_record.rrdatas]]} | |||
| def _data_for_NAPTR(self, gcloud_record): | |||
| return {'values': [{ | |||
| 'order': v[0], | |||
| 'preference': v[1], | |||
| 'flags': v[2], | |||
| 'service': v[3], | |||
| 'regexp': v[4], | |||
| 'replacement': v[5]} | |||
| for v in [shlex.split(g) for g in gcloud_record.rrdatas]]} | |||
| _data_for_NS = _data_for_A | |||
| _data_for_PTR = _data_for_CNAME | |||
| def _data_for_SPF(self, gcloud_record): | |||
| if len(gcloud_record.rrdatas) > 1: | |||
| return { | |||
| 'values': gcloud_record.rrdatas} | |||
| return { | |||
| 'value': gcloud_record.rrdatas[0]} | |||
| def _data_for_SRV(self, gcloud_record): | |||
| return {'values': [{ | |||
| 'priority': v[0], | |||
| 'weight': v[1], | |||
| 'port': v[2], | |||
| 'target': v[3]} | |||
| for v in [shlex.split(g) for g in gcloud_record.rrdatas]]} | |||
| _data_for_TXT = _data_for_SPF | |||
| def _rrset_for_A(self, gcloud_zone, record): | |||
| return gcloud_zone.resource_record_set( | |||
| record.fqdn, record._type, record.ttl, record.values) | |||
| _rrset_for_AAAA = _rrset_for_A | |||
| def _rrset_for_CAA(self, gcloud_zone, record): | |||
| return gcloud_zone.resource_record_set( | |||
| record.fqdn, record._type, record.ttl, [ | |||
| '{} {} {}'.format(v.flags, v.tag, v.value) | |||
| for v in record.values]) | |||
| def _rrset_for_CNAME(self, gcloud_zone, record): | |||
| return gcloud_zone.resource_record_set( | |||
| record.fqdn, record._type, record.ttl, [record.value]) | |||
| def _rrset_for_MX(self, gcloud_zone, record): | |||
| return gcloud_zone.resource_record_set( | |||
| record.fqdn, record._type, record.ttl, [ | |||
| '{} {}'.format(v.preference, v.exchange) | |||
| for v in record.values]) | |||
| def _rrset_for_NAPTR(self, gcloud_zone, record): | |||
| return gcloud_zone.resource_record_set( | |||
| record.fqdn, record._type, record.ttl, [ | |||
| '{} {} "{}" "{}" "{}" {}'.format( | |||
| v.order, v.preference, v.flags, v.service, | |||
| v.regexp, v.replacement) for v in record.values]) | |||
| _rrset_for_NS = _rrset_for_A | |||
| _rrset_for_PTR = _rrset_for_CNAME | |||
| def _rrset_for_SPF(self, gcloud_zone, record): | |||
| return gcloud_zone.resource_record_set( | |||
| record.fqdn, record._type, record.ttl, record.chunked_values) | |||
| def _rrset_for_SRV(self, gcloud_zone, record): | |||
| return gcloud_zone.resource_record_set( | |||
| record.fqdn, record._type, record.ttl, [ | |||
| '{} {} {} {}' | |||
| .format(v.priority, v.weight, v.port, v.target) | |||
| for v in record.values]) | |||
| _rrset_for_TXT = _rrset_for_SPF | |||
| @ -0,0 +1,322 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| import logging | |||
| from collections import defaultdict | |||
| import ovh | |||
| from octodns.record import Record | |||
| from .base import BaseProvider | |||
| class OvhProvider(BaseProvider): | |||
| """ | |||
| OVH provider using API v6 | |||
| ovh: | |||
| class: octodns.provider.ovh.OvhProvider | |||
| # OVH api v6 endpoint | |||
| endpoint: ovh-eu | |||
| # API application key | |||
| application_key: 1234 | |||
| # API application secret | |||
| application_secret: 1234 | |||
| # API consumer key | |||
| consumer_key: 1234 | |||
| """ | |||
| SUPPORTS_GEO = False | |||
| SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', 'SPF', | |||
| 'SRV', 'SSHFP', 'TXT')) | |||
| def __init__(self, id, endpoint, application_key, application_secret, | |||
| consumer_key, *args, **kwargs): | |||
| self.log = logging.getLogger('OvhProvider[{}]'.format(id)) | |||
| self.log.debug('__init__: id=%s, endpoint=%s, application_key=%s, ' | |||
| 'application_secret=***, consumer_key=%s', id, endpoint, | |||
| application_key, consumer_key) | |||
| super(OvhProvider, self).__init__(id, *args, **kwargs) | |||
| self._client = ovh.Client( | |||
| endpoint=endpoint, | |||
| application_key=application_key, | |||
| application_secret=application_secret, | |||
| consumer_key=consumer_key, | |||
| ) | |||
| def populate(self, zone, target=False, lenient=False): | |||
| self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, | |||
| target, lenient) | |||
| zone_name = zone.name[:-1] | |||
| records = self.get_records(zone_name=zone_name) | |||
| values = defaultdict(lambda: defaultdict(list)) | |||
| for record in records: | |||
| values[record['subDomain']][record['fieldType']].append(record) | |||
| before = len(zone.records) | |||
| for name, types in values.items(): | |||
| for _type, records in types.items(): | |||
| data_for = getattr(self, '_data_for_{}'.format(_type)) | |||
| record = Record.new(zone, name, data_for(_type, records), | |||
| source=self, lenient=lenient) | |||
| zone.add_record(record) | |||
| self.log.info('populate: found %s records', | |||
| len(zone.records) - before) | |||
| def _apply(self, plan): | |||
| desired = plan.desired | |||
| changes = plan.changes | |||
| zone_name = desired.name[:-1] | |||
| self.log.info('_apply: zone=%s, len(changes)=%d', desired.name, | |||
| len(changes)) | |||
| for change in changes: | |||
| class_name = change.__class__.__name__ | |||
| getattr(self, '_apply_{}'.format(class_name).lower())(zone_name, | |||
| change) | |||
| # We need to refresh the zone to really apply the changes | |||
| self._client.post('/domain/zone/{}/refresh'.format(zone_name)) | |||
| def _apply_create(self, zone_name, change): | |||
| new = change.new | |||
| params_for = getattr(self, '_params_for_{}'.format(new._type)) | |||
| for params in params_for(new): | |||
| self.create_record(zone_name, params) | |||
| def _apply_update(self, zone_name, change): | |||
| self._apply_delete(zone_name, change) | |||
| self._apply_create(zone_name, change) | |||
| def _apply_delete(self, zone_name, change): | |||
| existing = change.existing | |||
| self.delete_records(zone_name, existing._type, existing.name) | |||
| @staticmethod | |||
| def _data_for_multiple(_type, records): | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': [record['target'] for record in records] | |||
| } | |||
| @staticmethod | |||
| def _data_for_single(_type, records): | |||
| record = records[0] | |||
| return { | |||
| 'ttl': record['ttl'], | |||
| 'type': _type, | |||
| 'value': record['target'] | |||
| } | |||
| @staticmethod | |||
| def _data_for_MX(_type, records): | |||
| values = [] | |||
| for record in records: | |||
| preference, exchange = record['target'].split(' ', 1) | |||
| values.append({ | |||
| 'preference': preference, | |||
| 'exchange': exchange, | |||
| }) | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': values, | |||
| } | |||
| @staticmethod | |||
| def _data_for_NAPTR(_type, records): | |||
| values = [] | |||
| for record in records: | |||
| order, preference, flags, service, regexp, replacement = record[ | |||
| 'target'].split(' ', 5) | |||
| values.append({ | |||
| 'flags': flags[1:-1], | |||
| 'order': order, | |||
| 'preference': preference, | |||
| 'regexp': regexp[1:-1], | |||
| 'replacement': replacement, | |||
| 'service': service[1:-1], | |||
| }) | |||
| return { | |||
| 'type': _type, | |||
| 'ttl': records[0]['ttl'], | |||
| 'values': values | |||
| } | |||
| @staticmethod | |||
| def _data_for_SRV(_type, records): | |||
| values = [] | |||
| for record in records: | |||
| priority, weight, port, target = record['target'].split(' ', 3) | |||
| values.append({ | |||
| 'port': port, | |||
| 'priority': priority, | |||
| 'target': '{}.'.format(target), | |||
| 'weight': weight | |||
| }) | |||
| return { | |||
| 'type': _type, | |||
| 'ttl': records[0]['ttl'], | |||
| 'values': values | |||
| } | |||
| @staticmethod | |||
| def _data_for_SSHFP(_type, records): | |||
| values = [] | |||
| for record in records: | |||
| algorithm, fingerprint_type, fingerprint = record['target'].split( | |||
| ' ', 2) | |||
| values.append({ | |||
| 'algorithm': algorithm, | |||
| 'fingerprint': fingerprint, | |||
| 'fingerprint_type': fingerprint_type | |||
| }) | |||
| return { | |||
| 'type': _type, | |||
| 'ttl': records[0]['ttl'], | |||
| 'values': values | |||
| } | |||
| _data_for_A = _data_for_multiple | |||
| _data_for_AAAA = _data_for_multiple | |||
| _data_for_NS = _data_for_multiple | |||
| _data_for_TXT = _data_for_multiple | |||
| _data_for_SPF = _data_for_multiple | |||
| _data_for_PTR = _data_for_single | |||
| _data_for_CNAME = _data_for_single | |||
| @staticmethod | |||
| def _params_for_multiple(record): | |||
| for value in record.values: | |||
| yield { | |||
| 'target': value, | |||
| 'subDomain': record.name, | |||
| 'ttl': record.ttl, | |||
| 'fieldType': record._type, | |||
| } | |||
| @staticmethod | |||
| def _params_for_single(record): | |||
| yield { | |||
| 'target': record.value, | |||
| 'subDomain': record.name, | |||
| 'ttl': record.ttl, | |||
| 'fieldType': record._type | |||
| } | |||
| @staticmethod | |||
| def _params_for_MX(record): | |||
| for value in record.values: | |||
| yield { | |||
| 'target': '%d %s' % (value.preference, value.exchange), | |||
| 'subDomain': record.name, | |||
| 'ttl': record.ttl, | |||
| 'fieldType': record._type | |||
| } | |||
| @staticmethod | |||
| def _params_for_NAPTR(record): | |||
| for value in record.values: | |||
| content = '{} {} "{}" "{}" "{}" {}' \ | |||
| .format(value.order, value.preference, value.flags, | |||
| value.service, value.regexp, value.replacement) | |||
| yield { | |||
| 'target': content, | |||
| 'subDomain': record.name, | |||
| 'ttl': record.ttl, | |||
| 'fieldType': record._type | |||
| } | |||
| @staticmethod | |||
| def _params_for_SRV(record): | |||
| for value in record.values: | |||
| yield { | |||
| 'subDomain': '{} {} {} {}'.format(value.priority, | |||
| value.weight, value.port, | |||
| value.target), | |||
| 'target': record.name, | |||
| 'ttl': record.ttl, | |||
| 'fieldType': record._type | |||
| } | |||
| @staticmethod | |||
| def _params_for_SSHFP(record): | |||
| for value in record.values: | |||
| yield { | |||
| 'subDomain': '{} {} {}'.format(value.algorithm, | |||
| value.fingerprint_type, | |||
| value.fingerprint), | |||
| 'target': record.name, | |||
| 'ttl': record.ttl, | |||
| 'fieldType': record._type | |||
| } | |||
| _params_for_A = _params_for_multiple | |||
| _params_for_AAAA = _params_for_multiple | |||
| _params_for_NS = _params_for_multiple | |||
| _params_for_SPF = _params_for_multiple | |||
| _params_for_TXT = _params_for_multiple | |||
| _params_for_CNAME = _params_for_single | |||
| _params_for_PTR = _params_for_single | |||
| def get_records(self, zone_name): | |||
| """ | |||
| List all records of a DNS zone | |||
| :param zone_name: Name of zone | |||
| :return: list of id's records | |||
| """ | |||
| records = self._client.get('/domain/zone/{}/record'.format(zone_name)) | |||
| return [self.get_record(zone_name, record_id) for record_id in records] | |||
| def get_record(self, zone_name, record_id): | |||
| """ | |||
| Get record with given id | |||
| :param zone_name: Name of the zone | |||
| :param record_id: Id of the record | |||
| :return: Value of the record | |||
| """ | |||
| return self._client.get( | |||
| '/domain/zone/{}/record/{}'.format(zone_name, record_id)) | |||
| def delete_records(self, zone_name, record_type, subdomain): | |||
| """ | |||
| Delete record from have fieldType=type and subDomain=subdomain | |||
| :param zone_name: Name of the zone | |||
| :param record_type: fieldType | |||
| :param subdomain: subDomain | |||
| """ | |||
| records = self._client.get('/domain/zone/{}/record'.format(zone_name), | |||
| fieldType=record_type, subDomain=subdomain) | |||
| for record in records: | |||
| self.delete_record(zone_name, record) | |||
| def delete_record(self, zone_name, record_id): | |||
| """ | |||
| Delete record with a given id | |||
| :param zone_name: Name of the zone | |||
| :param record_id: Id of the record | |||
| """ | |||
| self.log.debug('Delete record: zone: %s, id %s', zone_name, | |||
| record_id) | |||
| self._client.delete( | |||
| '/domain/zone/{}/record/{}'.format(zone_name, record_id)) | |||
| def create_record(self, zone_name, params): | |||
| """ | |||
| Create a record | |||
| :param zone_name: Name of the zone | |||
| :param params: {'fieldType': 'A', 'ttl': 60, 'subDomain': 'www', | |||
| 'target': '1.2.3.4' | |||
| """ | |||
| self.log.debug('Create record: zone: %s, id %s', zone_name, | |||
| params) | |||
| return self._client.post('/domain/zone/{}/record'.format(zone_name), | |||
| **params) | |||
| @ -1,18 +1,23 @@ | |||
| # These are known good versions. You're free to use others and things will | |||
| # likely work, but no promises are made, especilly if you go older. | |||
| PyYaml==3.12 | |||
| boto3==1.4.4 | |||
| botocore==1.5.4 | |||
| azure-mgmt-dns==1.0.1 | |||
| azure-common==1.1.6 | |||
| boto3==1.4.6 | |||
| botocore==1.6.8 | |||
| dnspython==1.15.0 | |||
| docutils==0.13.1 | |||
| dyn==1.7.10 | |||
| futures==3.0.5 | |||
| docutils==0.14 | |||
| dyn==1.8.0 | |||
| futures==3.1.1 | |||
| google-cloud==0.27.0 | |||
| incf.countryutils==1.0 | |||
| ipaddress==1.0.18 | |||
| jmespath==0.9.0 | |||
| jmespath==0.9.3 | |||
| msrestazure==0.4.10 | |||
| natsort==5.0.3 | |||
| nsone==0.9.10 | |||
| python-dateutil==2.6.0 | |||
| nsone==0.9.14 | |||
| ovh==0.4.7 | |||
| python-dateutil==2.6.1 | |||
| requests==2.13.0 | |||
| s3transfer==0.1.10 | |||
| six==1.10.0 | |||
| @ -0,0 +1,14 @@ | |||
| #!/bin/bash | |||
| set -e | |||
| cd "$(dirname $0)"/.. | |||
| ROOT=$(pwd) | |||
| VERSION=$(grep __VERSION__ $ROOT/octodns/__init__.py | sed -e "s/.* = '//" -e "s/'$//") | |||
| git tag -s v$VERSION -m "Release $VERSION" | |||
| git push origin v$VERSION | |||
| echo "Tagged and pushed v$VERSION" | |||
| python setup.py sdist upload | |||
| echo "Updloaded $VERSION" | |||
| @ -0,0 +1,379 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from octodns.record import Create, Delete, Record | |||
| from octodns.provider.azuredns import _AzureRecord, AzureProvider, \ | |||
| _check_endswith_dot, _parse_azure_type | |||
| from octodns.zone import Zone | |||
| from octodns.provider.base import Plan | |||
| from azure.mgmt.dns.models import ARecord, AaaaRecord, CnameRecord, MxRecord, \ | |||
| SrvRecord, NsRecord, PtrRecord, TxtRecord, RecordSet, SoaRecord, \ | |||
| Zone as AzureZone | |||
| from msrestazure.azure_exceptions import CloudError | |||
| from unittest import TestCase | |||
| from mock import Mock, patch | |||
| 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, '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, '_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']})) | |||
| 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['arecords'] = [ARecord('1.2.3.4'), ARecord('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['arecords'] = [ARecord('1.2.3.4'), ARecord('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['arecords'] = ARecord('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['arecords'] = ARecord('1.1.1.3') | |||
| azure_records.append(_base3) | |||
| _base4 = _AzureRecord('TestAzure', octo_records[4]) | |||
| _base4.zone_name = 'unit.tests' | |||
| _base4.relative_record_set_name = 'cname' | |||
| _base4.record_type = 'CNAME' | |||
| _base4.params['ttl'] = 3 | |||
| _base4.params['cname_record'] = CnameRecord('a.unit.tests.') | |||
| azure_records.append(_base4) | |||
| _base5 = _AzureRecord('TestAzure', octo_records[5]) | |||
| _base5.zone_name = 'unit.tests' | |||
| _base5.relative_record_set_name = 'mx1' | |||
| _base5.record_type = 'MX' | |||
| _base5.params['ttl'] = 3 | |||
| _base5.params['mx_records'] = [MxRecord(10, 'mx1.unit.tests.'), | |||
| MxRecord(20, 'mx2.unit.tests.')] | |||
| azure_records.append(_base5) | |||
| _base6 = _AzureRecord('TestAzure', octo_records[6]) | |||
| _base6.zone_name = 'unit.tests' | |||
| _base6.relative_record_set_name = 'mx2' | |||
| _base6.record_type = 'MX' | |||
| _base6.params['ttl'] = 3 | |||
| _base6.params['mx_records'] = [MxRecord(10, 'mx1.unit.tests.')] | |||
| azure_records.append(_base6) | |||
| _base7 = _AzureRecord('TestAzure', octo_records[7]) | |||
| _base7.zone_name = 'unit.tests' | |||
| _base7.relative_record_set_name = '@' | |||
| _base7.record_type = 'NS' | |||
| _base7.params['ttl'] = 4 | |||
| _base7.params['ns_records'] = [NsRecord('ns1.unit.tests.'), | |||
| NsRecord('ns2.unit.tests.')] | |||
| azure_records.append(_base7) | |||
| _base8 = _AzureRecord('TestAzure', octo_records[8]) | |||
| _base8.zone_name = 'unit.tests' | |||
| _base8.relative_record_set_name = 'foo' | |||
| _base8.record_type = 'NS' | |||
| _base8.params['ttl'] = 5 | |||
| _base8.params['ns_records'] = [NsRecord('ns1.unit.tests.')] | |||
| azure_records.append(_base8) | |||
| _base9 = _AzureRecord('TestAzure', octo_records[9]) | |||
| _base9.zone_name = 'unit.tests' | |||
| _base9.relative_record_set_name = '_srv._tcp' | |||
| _base9.record_type = 'SRV' | |||
| _base9.params['ttl'] = 6 | |||
| _base9.params['srv_records'] = [SrvRecord(10, 20, 30, 'foo-1.unit.tests.'), | |||
| SrvRecord(12, 30, 30, 'foo-2.unit.tests.')] | |||
| azure_records.append(_base9) | |||
| _base10 = _AzureRecord('TestAzure', octo_records[10]) | |||
| _base10.zone_name = 'unit.tests' | |||
| _base10.relative_record_set_name = '_srv2._tcp' | |||
| _base10.record_type = 'SRV' | |||
| _base10.params['ttl'] = 7 | |||
| _base10.params['srv_records'] = [SrvRecord(12, 17, 1, 'srvfoo.unit.tests.')] | |||
| azure_records.append(_base10) | |||
| _base11 = _AzureRecord('TestAzure', octo_records[11]) | |||
| _base11.zone_name = 'unit.tests' | |||
| _base11.relative_record_set_name = 'txt1' | |||
| _base11.record_type = 'TXT' | |||
| _base11.params['ttl'] = 8 | |||
| _base11.params['txt_records'] = [TxtRecord(['txt singleton test'])] | |||
| azure_records.append(_base11) | |||
| _base12 = _AzureRecord('TestAzure', octo_records[12]) | |||
| _base12.zone_name = 'unit.tests' | |||
| _base12.relative_record_set_name = 'txt2' | |||
| _base12.record_type = 'TXT' | |||
| _base12.params['ttl'] = 9 | |||
| _base12.params['txt_records'] = [TxtRecord(['txt multiple test']), | |||
| TxtRecord(['txt multiple test 2'])] | |||
| azure_records.append(_base12) | |||
| 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_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 TestAzureDnsProvider(TestCase): | |||
| def _provider(self): | |||
| return self._get_provider('mock_spc', 'mock_dns_client') | |||
| @patch('octodns.provider.azuredns.DnsManagementClient') | |||
| @patch('octodns.provider.azuredns.ServicePrincipalCredentials') | |||
| def _get_provider(self, mock_spc, mock_dns_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 | |||
| :type return: AzureProvider | |||
| ''' | |||
| return AzureProvider('mock_id', 'mock_client', 'mock_key', | |||
| 'mock_directory', 'mock_sub', 'mock_rg') | |||
| def test_populate_records(self): | |||
| provider = self._get_provider() | |||
| rs = [] | |||
| rs.append(RecordSet(name='a1', ttl=0, type='A', | |||
| arecords=[ARecord('1.1.1.1')])) | |||
| rs.append(RecordSet(name='a2', ttl=1, type='A', | |||
| arecords=[ARecord('1.1.1.1'), | |||
| ARecord('2.2.2.2')])) | |||
| rs.append(RecordSet(name='aaaa1', ttl=2, type='AAAA', | |||
| aaaa_records=[AaaaRecord('1:1ec:1::1')])) | |||
| rs.append(RecordSet(name='aaaa2', ttl=3, type='AAAA', | |||
| aaaa_records=[AaaaRecord('1:1ec:1::1'), | |||
| AaaaRecord('1:1ec:1::2')])) | |||
| rs.append(RecordSet(name='cname1', ttl=4, type='CNAME', | |||
| cname_record=CnameRecord('cname.unit.test.'))) | |||
| rs.append(RecordSet(name='cname2', ttl=5, type='CNAME', | |||
| cname_record=None)) | |||
| rs.append(RecordSet(name='mx1', ttl=6, type='MX', | |||
| mx_records=[MxRecord(10, 'mx1.unit.test.')])) | |||
| rs.append(RecordSet(name='mx2', ttl=7, type='MX', | |||
| mx_records=[MxRecord(10, 'mx1.unit.test.'), | |||
| MxRecord(11, 'mx2.unit.test.')])) | |||
| rs.append(RecordSet(name='ns1', ttl=8, type='NS', | |||
| ns_records=[NsRecord('ns1.unit.test.')])) | |||
| rs.append(RecordSet(name='ns2', ttl=9, type='NS', | |||
| ns_records=[NsRecord('ns1.unit.test.'), | |||
| NsRecord('ns2.unit.test.')])) | |||
| rs.append(RecordSet(name='ptr1', ttl=10, type='PTR', | |||
| ptr_records=[PtrRecord('ptr1.unit.test.')])) | |||
| rs.append(RecordSet(name='ptr2', ttl=11, type='PTR', | |||
| ptr_records=[PtrRecord(None)])) | |||
| rs.append(RecordSet(name='_srv1._tcp', ttl=12, type='SRV', | |||
| srv_records=[SrvRecord(1, 2, 3, '1unit.tests.')])) | |||
| rs.append(RecordSet(name='_srv2._tcp', ttl=13, type='SRV', | |||
| srv_records=[SrvRecord(1, 2, 3, '1unit.tests.'), | |||
| SrvRecord(4, 5, 6, '2unit.tests.')])) | |||
| rs.append(RecordSet(name='txt1', ttl=14, type='TXT', | |||
| txt_records=[TxtRecord('sample text1')])) | |||
| rs.append(RecordSet(name='txt2', ttl=15, type='TXT', | |||
| txt_records=[TxtRecord('sample text1'), | |||
| TxtRecord('sample text2')])) | |||
| rs.append(RecordSet(name='', ttl=16, type='SOA', | |||
| soa_record=[SoaRecord()])) | |||
| record_list = provider._dns_client.record_sets.list_by_dns_zone | |||
| record_list.return_value = rs | |||
| provider.populate(zone) | |||
| self.assertEquals(len(zone.records), 16) | |||
| def test_populate_zone(self): | |||
| provider = self._get_provider() | |||
| zone_list = provider._dns_client.zones.list_by_resource_group | |||
| zone_list.return_value = [AzureZone(location='global'), | |||
| AzureZone(location='global')] | |||
| provider._populate_zones() | |||
| self.assertEquals(len(provider._azure_zones), 1) | |||
| 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') | |||
| trip = False | |||
| try: | |||
| provider._check_zone('unit.test', create=False) | |||
| except CloudError: | |||
| trip = True | |||
| self.assertEquals(trip, True) | |||
| def test_apply(self): | |||
| provider = self._get_provider() | |||
| changes = [] | |||
| deletes = [] | |||
| for i in octo_records: | |||
| changes.append(Create(i)) | |||
| deletes.append(Delete(i)) | |||
| self.assertEquals(13, provider.apply(Plan(None, zone, changes))) | |||
| self.assertEquals(13, provider.apply(Plan(zone, zone, deletes))) | |||
| 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) | |||
| self.assertEquals(13, provider.apply(Plan(None, desired, changes))) | |||
| def test_check_zone_no_create(self): | |||
| provider = self._get_provider() | |||
| rs = [] | |||
| rs.append(RecordSet(name='a1', ttl=0, type='A', | |||
| arecords=[ARecord('1.1.1.1')])) | |||
| rs.append(RecordSet(name='a2', ttl=1, type='A', | |||
| arecords=[ARecord('1.1.1.1'), | |||
| ARecord('2.2.2.2')])) | |||
| 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) | |||
| provider.populate(Zone('unit3.test.', [])) | |||
| self.assertEquals(len(zone.records), 0) | |||
| @ -0,0 +1,429 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from octodns.record import Create, Delete, Update, Record | |||
| from octodns.provider.googlecloud import GoogleCloudProvider | |||
| from octodns.zone import Zone | |||
| from octodns.provider.base import Plan, BaseProvider | |||
| from unittest import TestCase | |||
| from mock import Mock, patch, PropertyMock | |||
| 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, '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, '_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']})) | |||
| octo_records.append(Record.new(zone, 'naptr', { | |||
| 'ttl': 9, | |||
| 'type': 'NAPTR', | |||
| 'values': [{ | |||
| 'order': 100, | |||
| 'preference': 10, | |||
| 'flags': 'S', | |||
| 'service': 'SIP+D2U', | |||
| 'regexp': "!^.*$!sip:customer-service@unit.tests!", | |||
| 'replacement': '_sip._udp.unit.tests.' | |||
| }]})) | |||
| octo_records.append(Record.new(zone, 'caa', { | |||
| 'ttl': 9, | |||
| 'type': 'CAA', | |||
| 'value': { | |||
| 'flags': 0, | |||
| 'tag': 'issue', | |||
| 'value': 'ca.unit.tests', | |||
| }})) | |||
| for record in octo_records: | |||
| zone.add_record(record) | |||
| # This is the format which the google API likes. | |||
| resource_record_sets = [ | |||
| ('unit.tests.', u'A', 0, [u'1.2.3.4', u'10.10.10.10']), | |||
| (u'a.unit.tests.', u'A', 1, [u'1.1.1.1', u'1.2.3.4']), | |||
| (u'aa.unit.tests.', u'A', 9001, [u'1.2.4.3']), | |||
| (u'aaa.unit.tests.', u'A', 2, [u'1.1.1.3']), | |||
| (u'cname.unit.tests.', u'CNAME', 3, [u'a.unit.tests.']), | |||
| (u'mx1.unit.tests.', u'MX', 3, | |||
| [u'10 mx1.unit.tests.', u'20 mx2.unit.tests.']), | |||
| (u'mx2.unit.tests.', u'MX', 3, [u'10 mx1.unit.tests.']), | |||
| ('unit.tests.', u'NS', 4, [u'ns1.unit.tests.', u'ns2.unit.tests.']), | |||
| (u'foo.unit.tests.', u'NS', 5, [u'ns1.unit.tests.']), | |||
| (u'_srv._tcp.unit.tests.', u'SRV', 6, | |||
| [u'10 20 30 foo-1.unit.tests.', u'12 30 30 foo-2.unit.tests.']), | |||
| (u'_srv2._tcp.unit.tests.', u'SRV', 7, [u'12 17 1 srvfoo.unit.tests.']), | |||
| (u'txt1.unit.tests.', u'TXT', 8, [u'txt singleton test']), | |||
| (u'txt2.unit.tests.', u'TXT', 9, | |||
| [u'txt multiple test', u'txt multiple test 2']), | |||
| (u'naptr.unit.tests.', u'NAPTR', 9, [ | |||
| u'100 10 "S" "SIP+D2U" "!^.*$!sip:customer-service@unit.tests!"' | |||
| u' _sip._udp.unit.tests.']), | |||
| (u'caa.unit.tests.', u'CAA', 9, [u'0 issue ca.unit.tests']) | |||
| ] | |||
| class DummyResourceRecordSet: | |||
| def __init__(self, record_name, record_type, ttl, rrdatas): | |||
| self.name = record_name | |||
| self.record_type = record_type | |||
| self.ttl = ttl | |||
| self.rrdatas = rrdatas | |||
| def __eq__(self, other): | |||
| try: | |||
| return self.name == other.name \ | |||
| and self.record_type == other.record_type \ | |||
| and self.ttl == other.ttl \ | |||
| and sorted(self.rrdatas) == sorted(other.rrdatas) | |||
| except: | |||
| return False | |||
| def __repr__(self): | |||
| return "{} {} {} {!s}"\ | |||
| .format(self.name, self.record_type, self.ttl, self.rrdatas) | |||
| def __hash__(self): | |||
| return hash(repr(self)) | |||
| class DummyGoogleCloudZone: | |||
| def __init__(self, dns_name, name=""): | |||
| self.dns_name = dns_name | |||
| self.name = name | |||
| def resource_record_set(self, *args): | |||
| return DummyResourceRecordSet(*args) | |||
| def list_resource_record_sets(self, *args): | |||
| pass | |||
| def create(self, *args, **kwargs): | |||
| pass | |||
| class DummyIterator: | |||
| """Returns a mock DummyIterator object to use in testing. | |||
| This is because API calls for google cloud DNS, if paged, contains a | |||
| "next_page_token", which can be used to grab a subsequent | |||
| iterator with more results. | |||
| :type return: DummyIterator | |||
| """ | |||
| def __init__(self, list_of_stuff, page_token=None): | |||
| self.iterable = iter(list_of_stuff) | |||
| self.next_page_token = page_token | |||
| def __iter__(self): | |||
| return self | |||
| def next(self): | |||
| return self.iterable.next() | |||
| class TestGoogleCloudProvider(TestCase): | |||
| @patch('octodns.provider.googlecloud.dns') | |||
| def _get_provider(*args): | |||
| '''Returns a mock GoogleCloudProvider object to use in testing. | |||
| :type return: GoogleCloudProvider | |||
| ''' | |||
| return GoogleCloudProvider(id=1, project="mock") | |||
| @patch('octodns.provider.googlecloud.dns') | |||
| def test___init__(self, *_): | |||
| self.assertIsInstance(GoogleCloudProvider(id=1, | |||
| credentials_file="test", | |||
| project="unit test"), | |||
| BaseProvider) | |||
| self.assertIsInstance(GoogleCloudProvider(id=1), | |||
| BaseProvider) | |||
| @patch('octodns.provider.googlecloud.time.sleep') | |||
| @patch('octodns.provider.googlecloud.dns') | |||
| def test__apply(self, *_): | |||
| class DummyDesired: | |||
| def __init__(self, name, changes): | |||
| self.name = name | |||
| self.changes = changes | |||
| apply_z = Zone("unit.tests.", []) | |||
| create_r = Record.new(apply_z, '', { | |||
| 'ttl': 0, | |||
| 'type': 'A', | |||
| 'values': ['1.2.3.4', '10.10.10.10']}) | |||
| delete_r = Record.new(apply_z, 'a', { | |||
| 'ttl': 1, | |||
| 'type': 'A', | |||
| 'values': ['1.2.3.4', '1.1.1.1']}) | |||
| update_existing_r = Record.new(apply_z, 'aa', { | |||
| 'ttl': 9001, | |||
| 'type': 'A', | |||
| 'values': ['1.2.4.3']}) | |||
| update_new_r = Record.new(apply_z, 'aa', { | |||
| 'ttl': 666, | |||
| 'type': 'A', | |||
| 'values': ['1.4.3.2']}) | |||
| gcloud_zone_mock = DummyGoogleCloudZone("unit.tests.", "unit-tests") | |||
| status_mock = Mock() | |||
| return_values_for_status = iter( | |||
| ["pending"] * 11 + ['done', 'done']) | |||
| type(status_mock).status = PropertyMock( | |||
| side_effect=return_values_for_status.next) | |||
| gcloud_zone_mock.changes = Mock(return_value=status_mock) | |||
| provider = self._get_provider() | |||
| provider.gcloud_client = Mock() | |||
| provider._gcloud_zones = {"unit.tests.": gcloud_zone_mock} | |||
| desired = Mock() | |||
| desired.name = "unit.tests." | |||
| changes = [] | |||
| changes.append(Create(create_r)) | |||
| changes.append(Delete(delete_r)) | |||
| changes.append(Update(existing=update_existing_r, new=update_new_r)) | |||
| provider.apply(Plan( | |||
| existing=[update_existing_r, delete_r], | |||
| desired=desired, | |||
| changes=changes | |||
| )) | |||
| calls_mock = gcloud_zone_mock.changes.return_value | |||
| mocked_calls = [] | |||
| for mock_call in calls_mock.add_record_set.mock_calls: | |||
| mocked_calls.append(mock_call[1][0]) | |||
| self.assertEqual(mocked_calls, [ | |||
| DummyResourceRecordSet( | |||
| 'unit.tests.', 'A', 0, ['1.2.3.4', '10.10.10.10']), | |||
| DummyResourceRecordSet( | |||
| 'aa.unit.tests.', 'A', 666, ['1.4.3.2']) | |||
| ]) | |||
| mocked_calls2 = [] | |||
| for mock_call in calls_mock.delete_record_set.mock_calls: | |||
| mocked_calls2.append(mock_call[1][0]) | |||
| self.assertEqual(mocked_calls2, [ | |||
| DummyResourceRecordSet( | |||
| 'a.unit.tests.', 'A', 1, ['1.2.3.4', '1.1.1.1']), | |||
| DummyResourceRecordSet( | |||
| 'aa.unit.tests.', 'A', 9001, ['1.2.4.3']) | |||
| ]) | |||
| type(status_mock).status = "pending" | |||
| with self.assertRaises(RuntimeError): | |||
| provider.apply(Plan( | |||
| existing=[update_existing_r, delete_r], | |||
| desired=desired, | |||
| changes=changes | |||
| )) | |||
| unsupported_change = Mock() | |||
| unsupported_change.__len__ = Mock(return_value=1) | |||
| type_mock = Mock() | |||
| type_mock._type = "A" | |||
| unsupported_change.record = type_mock | |||
| mock_plan = Mock() | |||
| type(mock_plan).desired = PropertyMock(return_value=DummyDesired( | |||
| "dummy name", [])) | |||
| type(mock_plan).changes = [unsupported_change] | |||
| with self.assertRaises(RuntimeError): | |||
| provider.apply(mock_plan) | |||
| def test__get_gcloud_client(self): | |||
| provider = self._get_provider() | |||
| self.assertIsInstance(provider, GoogleCloudProvider) | |||
| @patch('octodns.provider.googlecloud.dns') | |||
| def test_populate(self, _): | |||
| def _get_mock_zones(page_token=None): | |||
| if not page_token: | |||
| return DummyIterator([ | |||
| DummyGoogleCloudZone('example.com.'), | |||
| ], page_token="MOCK_PAGE_TOKEN") | |||
| elif page_token == "MOCK_PAGE_TOKEN": | |||
| return DummyIterator([ | |||
| DummyGoogleCloudZone('example2.com.'), | |||
| ], page_token="MOCK_PAGE_TOKEN2") | |||
| return DummyIterator([ | |||
| google_cloud_zone | |||
| ]) | |||
| def _get_mock_record_sets(page_token=None): | |||
| if not page_token: | |||
| return DummyIterator( | |||
| [DummyResourceRecordSet(*v) for v in | |||
| resource_record_sets[:3]], page_token="MOCK_PAGE_TOKEN") | |||
| elif page_token == "MOCK_PAGE_TOKEN": | |||
| return DummyIterator( | |||
| [DummyResourceRecordSet(*v) for v in | |||
| resource_record_sets[3:5]], page_token="MOCK_PAGE_TOKEN2") | |||
| return DummyIterator( | |||
| [DummyResourceRecordSet(*v) for v in resource_record_sets[5:]]) | |||
| google_cloud_zone = DummyGoogleCloudZone('unit.tests.') | |||
| provider = self._get_provider() | |||
| provider.gcloud_client.list_zones = Mock(side_effect=_get_mock_zones) | |||
| google_cloud_zone.list_resource_record_sets = Mock( | |||
| side_effect=_get_mock_record_sets) | |||
| self.assertEqual(provider.gcloud_zones.get("unit.tests.").dns_name, | |||
| "unit.tests.") | |||
| test_zone = Zone('unit.tests.', []) | |||
| provider.populate(test_zone) | |||
| # test_zone gets fed the same records as zone does, except it's in | |||
| # the format returned by google API, so after populate they should look | |||
| # excactly the same. | |||
| self.assertEqual(test_zone.records, zone.records) | |||
| test_zone2 = Zone('nonexistant.zone.', []) | |||
| provider.populate(test_zone2, False, False) | |||
| self.assertEqual(len(test_zone2.records), 0, | |||
| msg="Zone should not get records from wrong domain") | |||
| provider.SUPPORTS = set() | |||
| test_zone3 = Zone('unit.tests.', []) | |||
| provider.populate(test_zone3) | |||
| self.assertEqual(len(test_zone3.records), 0) | |||
| @patch('octodns.provider.googlecloud.dns') | |||
| def test_populate_corner_cases(self, _): | |||
| provider = self._get_provider() | |||
| test_zone = Zone('unit.tests.', []) | |||
| not_same_fqdn = DummyResourceRecordSet( | |||
| 'unit.tests.gr', u'A', 0, [u'1.2.3.4']), | |||
| provider._get_gcloud_records = Mock( | |||
| side_effect=[not_same_fqdn]) | |||
| provider._gcloud_zones = { | |||
| "unit.tests.": DummyGoogleCloudZone("unit.tests.", "unit-tests")} | |||
| provider.populate(test_zone) | |||
| self.assertEqual(len(test_zone.records), 1) | |||
| self.assertEqual(test_zone.records.pop().fqdn, | |||
| u'unit.tests.gr.unit.tests.') | |||
| def test__get_gcloud_zone(self): | |||
| provider = self._get_provider() | |||
| provider.gcloud_client = Mock() | |||
| provider.gcloud_client.list_zones = Mock( | |||
| return_value=DummyIterator([])) | |||
| self.assertIsNone(provider.gcloud_zones.get("nonexistant.xone"), | |||
| msg="Check that nonexistant zones return None when" | |||
| "there's no create=True flag") | |||
| def test__get_rrsets(self): | |||
| provider = self._get_provider() | |||
| dummy_gcloud_zone = DummyGoogleCloudZone("unit.tests") | |||
| for octo_record in octo_records: | |||
| _rrset_func = getattr( | |||
| provider, '_rrset_for_{}'.format(octo_record._type)) | |||
| self.assertEqual( | |||
| _rrset_func(dummy_gcloud_zone, octo_record).record_type, | |||
| octo_record._type | |||
| ) | |||
| def test__create_zone(self): | |||
| provider = self._get_provider() | |||
| provider.gcloud_client = Mock() | |||
| provider.gcloud_client.list_zones = Mock( | |||
| return_value=DummyIterator([])) | |||
| mock_zone = provider._create_gcloud_zone("nonexistant.zone.mock") | |||
| mock_zone.create.assert_called() | |||
| provider.gcloud_client.zone.assert_called() | |||
| @ -0,0 +1,359 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from unittest import TestCase | |||
| from mock import patch, call | |||
| from ovh import APIError | |||
| from octodns.provider.ovh import OvhProvider | |||
| from octodns.record import Record | |||
| from octodns.zone import Zone | |||
| class TestOvhProvider(TestCase): | |||
| api_record = [] | |||
| zone = Zone('unit.tests.', []) | |||
| expected = set() | |||
| # A, subdomain='' | |||
| api_record.append({ | |||
| 'fieldType': 'A', | |||
| 'ttl': 100, | |||
| 'target': '1.2.3.4', | |||
| 'subDomain': '', | |||
| 'id': 1 | |||
| }) | |||
| expected.add(Record.new(zone, '', { | |||
| 'ttl': 100, | |||
| 'type': 'A', | |||
| 'value': '1.2.3.4', | |||
| })) | |||
| # A, subdomain='sub | |||
| api_record.append({ | |||
| 'fieldType': 'A', | |||
| 'ttl': 200, | |||
| 'target': '1.2.3.4', | |||
| 'subDomain': 'sub', | |||
| 'id': 2 | |||
| }) | |||
| expected.add(Record.new(zone, 'sub', { | |||
| 'ttl': 200, | |||
| 'type': 'A', | |||
| 'value': '1.2.3.4', | |||
| })) | |||
| # CNAME | |||
| api_record.append({ | |||
| 'fieldType': 'CNAME', | |||
| 'ttl': 300, | |||
| 'target': 'unit.tests.', | |||
| 'subDomain': 'www2', | |||
| 'id': 3 | |||
| }) | |||
| expected.add(Record.new(zone, 'www2', { | |||
| 'ttl': 300, | |||
| 'type': 'CNAME', | |||
| 'value': 'unit.tests.', | |||
| })) | |||
| # MX | |||
| api_record.append({ | |||
| 'fieldType': 'MX', | |||
| 'ttl': 400, | |||
| 'target': '10 mx1.unit.tests.', | |||
| 'subDomain': '', | |||
| 'id': 4 | |||
| }) | |||
| expected.add(Record.new(zone, '', { | |||
| 'ttl': 400, | |||
| 'type': 'MX', | |||
| 'values': [{ | |||
| 'preference': 10, | |||
| 'exchange': 'mx1.unit.tests.', | |||
| }] | |||
| })) | |||
| # NAPTR | |||
| api_record.append({ | |||
| 'fieldType': 'NAPTR', | |||
| 'ttl': 500, | |||
| 'target': '10 100 "S" "SIP+D2U" "!^.*$!sip:info@bar.example.com!" .', | |||
| 'subDomain': 'naptr', | |||
| 'id': 5 | |||
| }) | |||
| expected.add(Record.new(zone, 'naptr', { | |||
| 'ttl': 500, | |||
| 'type': 'NAPTR', | |||
| 'values': [{ | |||
| 'flags': 'S', | |||
| 'order': 10, | |||
| 'preference': 100, | |||
| 'regexp': '!^.*$!sip:info@bar.example.com!', | |||
| 'replacement': '.', | |||
| 'service': 'SIP+D2U', | |||
| }] | |||
| })) | |||
| # NS | |||
| api_record.append({ | |||
| 'fieldType': 'NS', | |||
| 'ttl': 600, | |||
| 'target': 'ns1.unit.tests.', | |||
| 'subDomain': '', | |||
| 'id': 6 | |||
| }) | |||
| api_record.append({ | |||
| 'fieldType': 'NS', | |||
| 'ttl': 600, | |||
| 'target': 'ns2.unit.tests.', | |||
| 'subDomain': '', | |||
| 'id': 7 | |||
| }) | |||
| expected.add(Record.new(zone, '', { | |||
| 'ttl': 600, | |||
| 'type': 'NS', | |||
| 'values': ['ns1.unit.tests.', 'ns2.unit.tests.'], | |||
| })) | |||
| # NS with sub | |||
| api_record.append({ | |||
| 'fieldType': 'NS', | |||
| 'ttl': 700, | |||
| 'target': 'ns3.unit.tests.', | |||
| 'subDomain': 'www3', | |||
| 'id': 8 | |||
| }) | |||
| api_record.append({ | |||
| 'fieldType': 'NS', | |||
| 'ttl': 700, | |||
| 'target': 'ns4.unit.tests.', | |||
| 'subDomain': 'www3', | |||
| 'id': 9 | |||
| }) | |||
| expected.add(Record.new(zone, 'www3', { | |||
| 'ttl': 700, | |||
| 'type': 'NS', | |||
| 'values': ['ns3.unit.tests.', 'ns4.unit.tests.'], | |||
| })) | |||
| api_record.append({ | |||
| 'fieldType': 'SRV', | |||
| 'ttl': 800, | |||
| 'target': '10 20 30 foo-1.unit.tests.', | |||
| 'subDomain': '_srv._tcp', | |||
| 'id': 10 | |||
| }) | |||
| api_record.append({ | |||
| 'fieldType': 'SRV', | |||
| 'ttl': 800, | |||
| 'target': '40 50 60 foo-2.unit.tests.', | |||
| 'subDomain': '_srv._tcp', | |||
| 'id': 11 | |||
| }) | |||
| expected.add(Record.new(zone, '_srv._tcp', { | |||
| 'ttl': 800, | |||
| 'type': 'SRV', | |||
| 'values': [{ | |||
| 'priority': 10, | |||
| 'weight': 20, | |||
| 'port': 30, | |||
| 'target': 'foo-1.unit.tests.', | |||
| }, { | |||
| 'priority': 40, | |||
| 'weight': 50, | |||
| 'port': 60, | |||
| 'target': 'foo-2.unit.tests.', | |||
| }] | |||
| })) | |||
| # PTR | |||
| api_record.append({ | |||
| 'fieldType': 'PTR', | |||
| 'ttl': 900, | |||
| 'target': 'unit.tests.', | |||
| 'subDomain': '4', | |||
| 'id': 12 | |||
| }) | |||
| expected.add(Record.new(zone, '4', { | |||
| 'ttl': 900, | |||
| 'type': 'PTR', | |||
| 'value': 'unit.tests.' | |||
| })) | |||
| # SPF | |||
| api_record.append({ | |||
| 'fieldType': 'SPF', | |||
| 'ttl': 1000, | |||
| 'target': 'v=spf1 include:unit.texts.rerirect ~all', | |||
| 'subDomain': '', | |||
| 'id': 13 | |||
| }) | |||
| expected.add(Record.new(zone, '', { | |||
| 'ttl': 1000, | |||
| 'type': 'SPF', | |||
| 'value': 'v=spf1 include:unit.texts.rerirect ~all' | |||
| })) | |||
| # SSHFP | |||
| api_record.append({ | |||
| 'fieldType': 'SSHFP', | |||
| 'ttl': 1100, | |||
| 'target': '1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73 ', | |||
| 'subDomain': '', | |||
| 'id': 14 | |||
| }) | |||
| expected.add(Record.new(zone, '', { | |||
| 'ttl': 1100, | |||
| 'type': 'SSHFP', | |||
| 'value': { | |||
| 'algorithm': 1, | |||
| 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', | |||
| 'fingerprint_type': 1 | |||
| } | |||
| })) | |||
| # AAAA | |||
| api_record.append({ | |||
| 'fieldType': 'AAAA', | |||
| 'ttl': 1200, | |||
| 'target': '1:1ec:1::1', | |||
| 'subDomain': '', | |||
| 'id': 15 | |||
| }) | |||
| expected.add(Record.new(zone, '', { | |||
| 'ttl': 200, | |||
| 'type': 'AAAA', | |||
| 'value': '1:1ec:1::1', | |||
| })) | |||
| @patch('ovh.Client') | |||
| def test_populate(self, client_mock): | |||
| provider = OvhProvider('test', 'endpoint', 'application_key', | |||
| 'application_secret', 'consumer_key') | |||
| with patch.object(provider._client, 'get') as get_mock: | |||
| zone = Zone('unit.tests.', []) | |||
| get_mock.side_effect = APIError('boom') | |||
| with self.assertRaises(APIError) as ctx: | |||
| provider.populate(zone) | |||
| self.assertEquals(get_mock.side_effect, ctx.exception) | |||
| with patch.object(provider._client, 'get') as get_mock: | |||
| zone = Zone('unit.tests.', []) | |||
| get_returns = [[record['id'] for record in self.api_record]] | |||
| get_returns += self.api_record | |||
| get_mock.side_effect = get_returns | |||
| provider.populate(zone) | |||
| self.assertEquals(self.expected, zone.records) | |||
| @patch('ovh.Client') | |||
| def test_apply(self, client_mock): | |||
| provider = OvhProvider('test', 'endpoint', 'application_key', | |||
| 'application_secret', 'consumer_key') | |||
| desired = Zone('unit.tests.', []) | |||
| for r in self.expected: | |||
| desired.add_record(r) | |||
| with patch.object(provider._client, 'post') as get_mock: | |||
| plan = provider.plan(desired) | |||
| get_mock.side_effect = APIError('boom') | |||
| with self.assertRaises(APIError) as ctx: | |||
| provider.apply(plan) | |||
| self.assertEquals(get_mock.side_effect, ctx.exception) | |||
| with patch.object(provider._client, 'get') as get_mock: | |||
| get_returns = [[1, 2], { | |||
| 'fieldType': 'A', | |||
| 'ttl': 600, | |||
| 'target': '5.6.7.8', | |||
| 'subDomain': '', | |||
| 'id': 100 | |||
| }, {'fieldType': 'A', | |||
| 'ttl': 600, | |||
| 'target': '5.6.7.8', | |||
| 'subDomain': 'fake', | |||
| 'id': 101 | |||
| }] | |||
| get_mock.side_effect = get_returns | |||
| plan = provider.plan(desired) | |||
| with patch.object(provider._client, 'post') as post_mock: | |||
| with patch.object(provider._client, 'delete') as delete_mock: | |||
| with patch.object(provider._client, 'get') as get_mock: | |||
| get_mock.side_effect = [[100], [101]] | |||
| provider.apply(plan) | |||
| wanted_calls = [ | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'A', | |||
| subDomain=u'', target=u'1.2.3.4', ttl=100), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'SRV', | |||
| subDomain=u'10 20 30 foo-1.unit.tests.', | |||
| target='_srv._tcp', ttl=800), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'SRV', | |||
| subDomain=u'40 50 60 foo-2.unit.tests.', | |||
| target='_srv._tcp', ttl=800), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'PTR', subDomain='4', | |||
| target=u'unit.tests.', ttl=900), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'NS', subDomain='www3', | |||
| target=u'ns3.unit.tests.', ttl=700), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'NS', subDomain='www3', | |||
| target=u'ns4.unit.tests.', ttl=700), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'SSHFP', | |||
| subDomain=u'1 1 bf6b6825d2977c511a475bbefb88a' | |||
| u'ad54' | |||
| u'a92ac73', | |||
| target=u'', ttl=1100), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'AAAA', subDomain=u'', | |||
| target=u'1:1ec:1::1', ttl=200), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'MX', subDomain=u'', | |||
| target=u'10 mx1.unit.tests.', ttl=400), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'CNAME', subDomain='www2', | |||
| target=u'unit.tests.', ttl=300), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'SPF', subDomain=u'', | |||
| target=u'v=spf1 include:unit.texts.' | |||
| u'rerirect ~all', | |||
| ttl=1000), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'A', | |||
| subDomain='sub', target=u'1.2.3.4', ttl=200), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'NAPTR', subDomain='naptr', | |||
| target=u'10 100 "S" "SIP+D2U" "!^.*$!sip:' | |||
| u'info@bar' | |||
| u'.example.com!" .', | |||
| ttl=500), | |||
| call(u'/domain/zone/unit.tests/refresh')] | |||
| post_mock.assert_has_calls(wanted_calls) | |||
| # Get for delete calls | |||
| get_mock.assert_has_calls( | |||
| [call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'A', subDomain=u''), | |||
| call(u'/domain/zone/unit.tests/record', | |||
| fieldType=u'A', subDomain='fake')] | |||
| ) | |||
| # 2 delete calls, one for update + one for delete | |||
| delete_mock.assert_has_calls( | |||
| [call(u'/domain/zone/unit.tests/record/100'), | |||
| call(u'/domain/zone/unit.tests/record/101')]) | |||