| @ -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, \ | from __future__ import absolute_import, division, print_function, \ | ||||
| unicode_literals | 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 | # 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. | # likely work, but no promises are made, especilly if you go older. | ||||
| PyYaml==3.12 | 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 | 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 | incf.countryutils==1.0 | ||||
| ipaddress==1.0.18 | ipaddress==1.0.18 | ||||
| jmespath==0.9.0 | |||||
| jmespath==0.9.3 | |||||
| msrestazure==0.4.10 | |||||
| natsort==5.0.3 | 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 | requests==2.13.0 | ||||
| s3transfer==0.1.10 | s3transfer==0.1.10 | ||||
| six==1.10.0 | 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')]) | |||||