| @ -0,0 +1,378 @@ | |||||
| # | |||||
| # | |||||
| # | |||||
| from __future__ import absolute_import, division, print_function, \ | |||||
| unicode_literals | |||||
| from collections import defaultdict | |||||
| from requests import Session | |||||
| import logging | |||||
| from ..record import Record | |||||
| from .base import BaseProvider | |||||
| class GandiClientException(Exception): | |||||
| pass | |||||
| class GandiClientBadRequest(GandiClientException): | |||||
| def __init__(self, r): | |||||
| super(GandiClientBadRequest, self).__init__(r.text) | |||||
| class GandiClientUnauthorized(GandiClientException): | |||||
| def __init__(self, r): | |||||
| super(GandiClientUnauthorized, self).__init__(r.text) | |||||
| class GandiClientForbidden(GandiClientException): | |||||
| def __init__(self, r): | |||||
| super(GandiClientForbidden, self).__init__(r.text) | |||||
| class GandiClientNotFound(GandiClientException): | |||||
| def __init__(self, r): | |||||
| super(GandiClientNotFound, self).__init__(r.text) | |||||
| class GandiClientUnknownDomainName(GandiClientException): | |||||
| def __init__(self, msg): | |||||
| super(GandiClientUnknownDomainName, self).__init__(msg) | |||||
| class GandiClient(object): | |||||
| def __init__(self, token): | |||||
| session = Session() | |||||
| session.headers.update({'Authorization': 'Apikey {}'.format(token)}) | |||||
| self._session = session | |||||
| self.endpoint = 'https://api.gandi.net/v5' | |||||
| def _request(self, method, path, params={}, data=None): | |||||
| url = '{}{}'.format(self.endpoint, path) | |||||
| r = self._session.request(method, url, params=params, json=data) | |||||
| if r.status_code == 400: | |||||
| raise GandiClientBadRequest(r) | |||||
| if r.status_code == 401: | |||||
| raise GandiClientUnauthorized(r) | |||||
| elif r.status_code == 403: | |||||
| raise GandiClientForbidden(r) | |||||
| elif r.status_code == 404: | |||||
| raise GandiClientNotFound(r) | |||||
| r.raise_for_status() | |||||
| return r | |||||
| def zone(self, zone_name): | |||||
| return self._request('GET', '/livedns/domains/{}' | |||||
| .format(zone_name)).json() | |||||
| def zone_create(self, zone_name): | |||||
| return self._request('POST', '/livedns/domains', data={ | |||||
| 'fqdn': zone_name, | |||||
| 'zone': {} | |||||
| }).json() | |||||
| def zone_records(self, zone_name): | |||||
| records = self._request('GET', '/livedns/domains/{}/records' | |||||
| .format(zone_name)).json() | |||||
| for record in records: | |||||
| if record['rrset_name'] == '@': | |||||
| record['rrset_name'] = '' | |||||
| # Change relative targets to absolute ones. | |||||
| if record['rrset_type'] in ['ALIAS', 'CNAME', 'DNAME', 'MX', | |||||
| 'NS', 'SRV']: | |||||
| for i, value in enumerate(record['rrset_values']): | |||||
| if not value.endswith('.'): | |||||
| record['rrset_values'][i] = '{}.{}.'.format( | |||||
| value, zone_name) | |||||
| return records | |||||
| def record_create(self, zone_name, data): | |||||
| self._request('POST', '/livedns/domains/{}/records'.format(zone_name), | |||||
| data=data) | |||||
| def record_delete(self, zone_name, record_name, record_type): | |||||
| self._request('DELETE', '/livedns/domains/{}/records/{}/{}' | |||||
| .format(zone_name, record_name, record_type)) | |||||
| class GandiProvider(BaseProvider): | |||||
| ''' | |||||
| Gandi provider using API v5. | |||||
| gandi: | |||||
| class: octodns.provider.gandi.GandiProvider | |||||
| # Your API key (required) | |||||
| token: XXXXXXXXXXXX | |||||
| ''' | |||||
| SUPPORTS_GEO = False | |||||
| SUPPORTS_DYNAMIC = False | |||||
| SUPPORTS = set((['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', | |||||
| 'MX', 'NS', 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT'])) | |||||
| def __init__(self, id, token, *args, **kwargs): | |||||
| self.log = logging.getLogger('GandiProvider[{}]'.format(id)) | |||||
| self.log.debug('__init__: id=%s, token=***', id) | |||||
| super(GandiProvider, self).__init__(id, *args, **kwargs) | |||||
| self._client = GandiClient(token) | |||||
| self._zone_records = {} | |||||
| def _data_for_multiple(self, _type, records): | |||||
| return { | |||||
| 'ttl': records[0]['rrset_ttl'], | |||||
| 'type': _type, | |||||
| 'values': [v.replace(';', '\\;') for v in | |||||
| records[0]['rrset_values']] if _type == 'TXT' else | |||||
| records[0]['rrset_values'] | |||||
| } | |||||
| _data_for_A = _data_for_multiple | |||||
| _data_for_AAAA = _data_for_multiple | |||||
| _data_for_TXT = _data_for_multiple | |||||
| _data_for_SPF = _data_for_multiple | |||||
| _data_for_NS = _data_for_multiple | |||||
| def _data_for_CAA(self, _type, records): | |||||
| values = [] | |||||
| for record in records[0]['rrset_values']: | |||||
| flags, tag, value = record.split(' ') | |||||
| values.append({ | |||||
| 'flags': flags, | |||||
| 'tag': tag, | |||||
| # Remove quotes around value. | |||||
| 'value': value[1:-1], | |||||
| }) | |||||
| return { | |||||
| 'ttl': records[0]['rrset_ttl'], | |||||
| 'type': _type, | |||||
| 'values': values | |||||
| } | |||||
| def _data_for_single(self, _type, records): | |||||
| return { | |||||
| 'ttl': records[0]['rrset_ttl'], | |||||
| 'type': _type, | |||||
| 'value': records[0]['rrset_values'][0] | |||||
| } | |||||
| _data_for_ALIAS = _data_for_single | |||||
| _data_for_CNAME = _data_for_single | |||||
| _data_for_DNAME = _data_for_single | |||||
| _data_for_PTR = _data_for_single | |||||
| def _data_for_MX(self, _type, records): | |||||
| values = [] | |||||
| for record in records[0]['rrset_values']: | |||||
| priority, server = record.split(' ') | |||||
| values.append({ | |||||
| 'preference': priority, | |||||
| 'exchange': server | |||||
| }) | |||||
| return { | |||||
| 'ttl': records[0]['rrset_ttl'], | |||||
| 'type': _type, | |||||
| 'values': values | |||||
| } | |||||
| def _data_for_SRV(self, _type, records): | |||||
| values = [] | |||||
| for record in records[0]['rrset_values']: | |||||
| priority, weight, port, target = record.split(' ', 3) | |||||
| values.append({ | |||||
| 'priority': priority, | |||||
| 'weight': weight, | |||||
| 'port': port, | |||||
| 'target': target | |||||
| }) | |||||
| return { | |||||
| 'ttl': records[0]['rrset_ttl'], | |||||
| 'type': _type, | |||||
| 'values': values | |||||
| } | |||||
| def _data_for_SSHFP(self, _type, records): | |||||
| values = [] | |||||
| for record in records[0]['rrset_values']: | |||||
| algorithm, fingerprint_type, fingerprint = record.split(' ', 2) | |||||
| values.append({ | |||||
| 'algorithm': algorithm, | |||||
| 'fingerprint': fingerprint, | |||||
| 'fingerprint_type': fingerprint_type | |||||
| }) | |||||
| return { | |||||
| 'ttl': records[0]['rrset_ttl'], | |||||
| 'type': _type, | |||||
| 'values': values | |||||
| } | |||||
| def zone_records(self, zone): | |||||
| if zone.name not in self._zone_records: | |||||
| try: | |||||
| self._zone_records[zone.name] = \ | |||||
| self._client.zone_records(zone.name[:-1]) | |||||
| except GandiClientNotFound: | |||||
| return [] | |||||
| return self._zone_records[zone.name] | |||||
| def populate(self, zone, target=False, lenient=False): | |||||
| self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, | |||||
| target, lenient) | |||||
| values = defaultdict(lambda: defaultdict(list)) | |||||
| for record in self.zone_records(zone): | |||||
| _type = record['rrset_type'] | |||||
| if _type not in self.SUPPORTS: | |||||
| continue | |||||
| values[record['rrset_name']][record['rrset_type']].append(record) | |||||
| before = len(zone.records) | |||||
| for name, types in values.items(): | |||||
| for _type, records in types.items(): | |||||
| data_for = getattr(self, '_data_for_{}'.format(_type)) | |||||
| record = Record.new(zone, name, data_for(_type, records), | |||||
| source=self, lenient=lenient) | |||||
| zone.add_record(record, lenient=lenient) | |||||
| exists = zone.name in self._zone_records | |||||
| self.log.info('populate: found %s records, exists=%s', | |||||
| len(zone.records) - before, exists) | |||||
| return exists | |||||
| def _record_name(self, name): | |||||
| return name if name else '@' | |||||
| def _params_for_multiple(self, record): | |||||
| return { | |||||
| 'rrset_name': self._record_name(record.name), | |||||
| 'rrset_ttl': record.ttl, | |||||
| 'rrset_type': record._type, | |||||
| 'rrset_values': [v.replace('\\;', ';') for v in | |||||
| record.values] if record._type == 'TXT' | |||||
| else record.values | |||||
| } | |||||
| _params_for_A = _params_for_multiple | |||||
| _params_for_AAAA = _params_for_multiple | |||||
| _params_for_NS = _params_for_multiple | |||||
| _params_for_TXT = _params_for_multiple | |||||
| _params_for_SPF = _params_for_multiple | |||||
| def _params_for_CAA(self, record): | |||||
| return { | |||||
| 'rrset_name': self._record_name(record.name), | |||||
| 'rrset_ttl': record.ttl, | |||||
| 'rrset_type': record._type, | |||||
| 'rrset_values': ['{} {} "{}"'.format(v.flags, v.tag, v.value) | |||||
| for v in record.values] | |||||
| } | |||||
| def _params_for_single(self, record): | |||||
| return { | |||||
| 'rrset_name': self._record_name(record.name), | |||||
| 'rrset_ttl': record.ttl, | |||||
| 'rrset_type': record._type, | |||||
| 'rrset_values': [record.value] | |||||
| } | |||||
| _params_for_ALIAS = _params_for_single | |||||
| _params_for_CNAME = _params_for_single | |||||
| _params_for_DNAME = _params_for_single | |||||
| _params_for_PTR = _params_for_single | |||||
| def _params_for_MX(self, record): | |||||
| return { | |||||
| 'rrset_name': self._record_name(record.name), | |||||
| 'rrset_ttl': record.ttl, | |||||
| 'rrset_type': record._type, | |||||
| 'rrset_values': ['{} {}'.format(v.preference, v.exchange) | |||||
| for v in record.values] | |||||
| } | |||||
| def _params_for_SRV(self, record): | |||||
| return { | |||||
| 'rrset_name': self._record_name(record.name), | |||||
| 'rrset_ttl': record.ttl, | |||||
| 'rrset_type': record._type, | |||||
| 'rrset_values': ['{} {} {} {}'.format(v.priority, v.weight, v.port, | |||||
| v.target) for v in record.values] | |||||
| } | |||||
| def _params_for_SSHFP(self, record): | |||||
| return { | |||||
| 'rrset_name': self._record_name(record.name), | |||||
| 'rrset_ttl': record.ttl, | |||||
| 'rrset_type': record._type, | |||||
| 'rrset_values': ['{} {} {}'.format(v.algorithm, v.fingerprint_type, | |||||
| v.fingerprint) for v in record.values] | |||||
| } | |||||
| def _apply_create(self, change): | |||||
| new = change.new | |||||
| data = getattr(self, '_params_for_{}'.format(new._type))(new) | |||||
| self._client.record_create(new.zone.name[:-1], data) | |||||
| def _apply_update(self, change): | |||||
| self._apply_delete(change) | |||||
| self._apply_create(change) | |||||
| def _apply_delete(self, change): | |||||
| existing = change.existing | |||||
| zone = existing.zone | |||||
| self._client.record_delete(zone.name[:-1], | |||||
| self._record_name(existing.name), | |||||
| existing._type) | |||||
| def _apply(self, plan): | |||||
| desired = plan.desired | |||||
| changes = plan.changes | |||||
| zone = desired.name[:-1] | |||||
| self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, | |||||
| len(changes)) | |||||
| try: | |||||
| self._client.zone(zone) | |||||
| except GandiClientNotFound: | |||||
| self.log.info('_apply: no existing zone, trying to create it') | |||||
| try: | |||||
| self._client.zone_create(zone) | |||||
| self.log.info('_apply: zone has been successfully created') | |||||
| except GandiClientNotFound: | |||||
| # We suppress existing exception before raising | |||||
| # GandiClientUnknownDomainName. | |||||
| e = GandiClientUnknownDomainName('This domain is not ' | |||||
| 'registred at Gandi. ' | |||||
| 'Please register or ' | |||||
| 'transfer it here ' | |||||
| 'to be able to manage its ' | |||||
| 'DNS zone.') | |||||
| e.__cause__ = None | |||||
| raise e | |||||
| # Force records deletion to be done before creation in order to avoid | |||||
| # "CNAME record must be the only record" error when an existing CNAME | |||||
| # record is replaced by an A/AAAA record. | |||||
| changes.reverse() | |||||
| for change in changes: | |||||
| class_name = change.__class__.__name__ | |||||
| getattr(self, '_apply_{}'.format(class_name.lower()))(change) | |||||
| # Clear out the cache if any | |||||
| self._zone_records.pop(desired.name, None) | |||||
| @ -0,0 +1,21 @@ | |||||
| manager: | |||||
| max_workers: 2 | |||||
| providers: | |||||
| in: | |||||
| class: octodns.provider.yaml.YamlProvider | |||||
| directory: tests/config | |||||
| dump: | |||||
| class: octodns.provider.yaml.YamlProvider | |||||
| directory: env/YAML_TMP_DIR | |||||
| zones: | |||||
| unit.tests.: | |||||
| sources: | |||||
| - in | |||||
| targets: | |||||
| - dump | |||||
| alias.tests.: | |||||
| alias: unit.tests. | |||||
| alias-loop.tests.: | |||||
| alias: alias.tests. | |||||
| @ -0,0 +1,19 @@ | |||||
| manager: | |||||
| max_workers: 2 | |||||
| providers: | |||||
| in: | |||||
| class: octodns.provider.yaml.YamlProvider | |||||
| directory: tests/config | |||||
| dump: | |||||
| class: octodns.provider.yaml.YamlProvider | |||||
| directory: env/YAML_TMP_DIR | |||||
| zones: | |||||
| unit.tests.: | |||||
| sources: | |||||
| - in | |||||
| targets: | |||||
| - dump | |||||
| alias.tests.: | |||||
| alias: unit.tests. | |||||
| @ -0,0 +1,5 @@ | |||||
| --- | |||||
| dname: | |||||
| ttl: 300 | |||||
| type: DNAME | |||||
| value: unit.tests. | |||||
| @ -0,0 +1,18 @@ | |||||
| manager: | |||||
| max_workers: 2 | |||||
| providers: | |||||
| in: | |||||
| class: octodns.provider.yaml.YamlProvider | |||||
| directory: tests/config | |||||
| dump: | |||||
| class: octodns.provider.yaml.YamlProvider | |||||
| directory: env/YAML_TMP_DIR | |||||
| zones: | |||||
| unit.tests.: | |||||
| sources: | |||||
| - in | |||||
| targets: | |||||
| - dump | |||||
| alias.tests.: | |||||
| alias: does-not-exists.tests. | |||||
| @ -0,0 +1,136 @@ | |||||
| [ | |||||
| { | |||||
| "rrset_type": "A", | |||||
| "rrset_ttl": 300, | |||||
| "rrset_name": "@", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", | |||||
| "rrset_values": [ | |||||
| "1.2.3.4", | |||||
| "1.2.3.5" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "CAA", | |||||
| "rrset_ttl": 3600, | |||||
| "rrset_name": "@", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/CAA", | |||||
| "rrset_values": [ | |||||
| "0 issue \"ca.unit.tests\"" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "SSHFP", | |||||
| "rrset_ttl": 3600, | |||||
| "rrset_name": "@", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/SSHFP", | |||||
| "rrset_values": [ | |||||
| "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49", | |||||
| "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "AAAA", | |||||
| "rrset_ttl": 600, | |||||
| "rrset_name": "aaaa", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/aaaa/AAAA", | |||||
| "rrset_values": [ | |||||
| "2601:644:500:e210:62f8:1dff:feb8:947a" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "CNAME", | |||||
| "rrset_ttl": 300, | |||||
| "rrset_name": "cname", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/cname/CNAME", | |||||
| "rrset_values": [ | |||||
| "unit.tests." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "DNAME", | |||||
| "rrset_ttl": 300, | |||||
| "rrset_name": "dname", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/dname/DNAME", | |||||
| "rrset_values": [ | |||||
| "unit.tests." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "CNAME", | |||||
| "rrset_ttl": 3600, | |||||
| "rrset_name": "excluded", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/excluded/CNAME", | |||||
| "rrset_values": [ | |||||
| "unit.tests." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "MX", | |||||
| "rrset_ttl": 300, | |||||
| "rrset_name": "mx", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/mx/MX", | |||||
| "rrset_values": [ | |||||
| "10 smtp-4.unit.tests.", | |||||
| "20 smtp-2.unit.tests.", | |||||
| "30 smtp-3.unit.tests.", | |||||
| "40 smtp-1.unit.tests." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "PTR", | |||||
| "rrset_ttl": 300, | |||||
| "rrset_name": "ptr", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/ptr/PTR", | |||||
| "rrset_values": [ | |||||
| "foo.bar.com." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "SPF", | |||||
| "rrset_ttl": 600, | |||||
| "rrset_name": "spf", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/spf/SPF", | |||||
| "rrset_values": [ | |||||
| "\"v=spf1 ip4:192.168.0.1/16-all\"" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "TXT", | |||||
| "rrset_ttl": 600, | |||||
| "rrset_name": "txt", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/txt/TXT", | |||||
| "rrset_values": [ | |||||
| "\"Bah bah black sheep\"", | |||||
| "\"have you any wool.\"", | |||||
| "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "A", | |||||
| "rrset_ttl": 300, | |||||
| "rrset_name": "www", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/A", | |||||
| "rrset_values": [ | |||||
| "2.2.3.6" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "A", | |||||
| "rrset_ttl": 300, | |||||
| "rrset_name": "www.sub", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www.sub/A", | |||||
| "rrset_values": [ | |||||
| "2.2.3.6" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "SRV", | |||||
| "rrset_ttl": 600, | |||||
| "rrset_name": "_srv._tcp", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_srv._tcp/SRV", | |||||
| "rrset_values": [ | |||||
| "10 20 30 foo-1.unit.tests.", | |||||
| "12 20 30 foo-2.unit.tests." | |||||
| ] | |||||
| } | |||||
| ] | |||||
| @ -0,0 +1,111 @@ | |||||
| [ | |||||
| { | |||||
| "rrset_type": "A", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "@", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", | |||||
| "rrset_values": [ | |||||
| "217.70.184.38" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "MX", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "@", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/MX", | |||||
| "rrset_values": [ | |||||
| "10 spool.mail.gandi.net.", | |||||
| "50 fb.mail.gandi.net." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "TXT", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "@", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/TXT", | |||||
| "rrset_values": [ | |||||
| "\"v=spf1 include:_mailcust.gandi.net ?all\"" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "CNAME", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "webmail", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/webmail/CNAME", | |||||
| "rrset_values": [ | |||||
| "webmail.gandi.net." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "CNAME", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "www", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/CNAME", | |||||
| "rrset_values": [ | |||||
| "webredir.vip.gandi.net." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "SRV", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "_imap._tcp", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV", | |||||
| "rrset_values": [ | |||||
| "0 0 0 ." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "SRV", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "_imaps._tcp", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imaps._tcp/SRV", | |||||
| "rrset_values": [ | |||||
| "0 1 993 mail.gandi.net." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "SRV", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "_pop3._tcp", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV", | |||||
| "rrset_values": [ | |||||
| "0 0 0 ." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "SRV", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "_pop3s._tcp", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3s._tcp/SRV", | |||||
| "rrset_values": [ | |||||
| "10 1 995 mail.gandi.net." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "SRV", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "_submission._tcp", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_submission._tcp/SRV", | |||||
| "rrset_values": [ | |||||
| "0 1 465 mail.gandi.net." | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "CDS", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "sub", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/sub/CDS", | |||||
| "rrset_values": [ | |||||
| "32128 13 1 6823D9BB1B03DF714DD0EB163E20B341C96D18C0" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "rrset_type": "CNAME", | |||||
| "rrset_ttl": 10800, | |||||
| "rrset_name": "relative", | |||||
| "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/relative/CNAME", | |||||
| "rrset_values": [ | |||||
| "target" | |||||
| ] | |||||
| } | |||||
| ] | |||||
| @ -0,0 +1,7 @@ | |||||
| { | |||||
| "domain_keys_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/keys", | |||||
| "fqdn": "unit.tests", | |||||
| "automatic_snapshots": true, | |||||
| "domain_records_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records", | |||||
| "domain_href": "https://api.gandi.net/v5/livedns/domains/unit.tests" | |||||
| } | |||||
| @ -0,0 +1,361 @@ | |||||
| # | |||||
| # | |||||
| # | |||||
| from __future__ import absolute_import, division, print_function, \ | |||||
| unicode_literals | |||||
| from mock import Mock, call | |||||
| from os.path import dirname, join | |||||
| from requests import HTTPError | |||||
| from requests_mock import ANY, mock as requests_mock | |||||
| from six import text_type | |||||
| from unittest import TestCase | |||||
| from octodns.record import Record | |||||
| from octodns.provider.gandi import GandiProvider, GandiClientBadRequest, \ | |||||
| GandiClientUnauthorized, GandiClientForbidden, GandiClientNotFound, \ | |||||
| GandiClientUnknownDomainName | |||||
| from octodns.provider.yaml import YamlProvider | |||||
| from octodns.zone import Zone | |||||
| class TestGandiProvider(TestCase): | |||||
| expected = Zone('unit.tests.', []) | |||||
| source = YamlProvider('test', join(dirname(__file__), 'config')) | |||||
| source.populate(expected) | |||||
| # We remove this record from the test zone as Gandi API reject it | |||||
| # (rightfully). | |||||
| expected._remove_record(Record.new(expected, 'sub', { | |||||
| 'ttl': 1800, | |||||
| 'type': 'NS', | |||||
| 'values': [ | |||||
| '6.2.3.4.', | |||||
| '7.2.3.4.' | |||||
| ] | |||||
| })) | |||||
| def test_populate(self): | |||||
| provider = GandiProvider('test_id', 'token') | |||||
| # 400 - Bad Request. | |||||
| with requests_mock() as mock: | |||||
| mock.get(ANY, status_code=400, | |||||
| text='{"status": "error", "errors": [{"location": ' | |||||
| '"body", "name": "items", "description": ' | |||||
| '"\'6.2.3.4.\': invalid hostname (param: ' | |||||
| '{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, ' | |||||
| '\'rrset_name\': u\'sub\', \'rrset_values\': ' | |||||
| '[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}, {"location": ' | |||||
| '"body", "name": "items", "description": ' | |||||
| '"\'7.2.3.4.\': invalid hostname (param: ' | |||||
| '{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, ' | |||||
| '\'rrset_name\': u\'sub\', \'rrset_values\': ' | |||||
| '[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}]}') | |||||
| with self.assertRaises(GandiClientBadRequest) as ctx: | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider.populate(zone) | |||||
| self.assertIn('"status": "error"', text_type(ctx.exception)) | |||||
| # 401 - Unauthorized. | |||||
| with requests_mock() as mock: | |||||
| mock.get(ANY, status_code=401, | |||||
| text='{"code":401,"message":"The server could not verify ' | |||||
| 'that you authorized to access the document you ' | |||||
| 'requested. Either you supplied the wrong ' | |||||
| 'credentials (e.g., bad api key), or your access ' | |||||
| 'token has expired","object":"HTTPUnauthorized",' | |||||
| '"cause":"Unauthorized"}') | |||||
| with self.assertRaises(GandiClientUnauthorized) as ctx: | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider.populate(zone) | |||||
| self.assertIn('"cause":"Unauthorized"', text_type(ctx.exception)) | |||||
| # 403 - Forbidden. | |||||
| with requests_mock() as mock: | |||||
| mock.get(ANY, status_code=403, | |||||
| text='{"code":403,"message":"Access was denied to this ' | |||||
| 'resource.","object":"HTTPForbidden","cause":' | |||||
| '"Forbidden"}') | |||||
| with self.assertRaises(GandiClientForbidden) as ctx: | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider.populate(zone) | |||||
| self.assertIn('"cause":"Forbidden"', text_type(ctx.exception)) | |||||
| # 404 - Not Found. | |||||
| with requests_mock() as mock: | |||||
| mock.get(ANY, status_code=404, | |||||
| text='{"code": 404, "message": "The resource could not ' | |||||
| 'be found.", "object": "HTTPNotFound", "cause": ' | |||||
| '"Not Found"}') | |||||
| with self.assertRaises(GandiClientNotFound) as ctx: | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider._client.zone(zone) | |||||
| self.assertIn('"cause": "Not Found"', text_type(ctx.exception)) | |||||
| # General error | |||||
| with requests_mock() as mock: | |||||
| mock.get(ANY, status_code=502, text='Things caught fire') | |||||
| with self.assertRaises(HTTPError) as ctx: | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider.populate(zone) | |||||
| self.assertEquals(502, ctx.exception.response.status_code) | |||||
| # No diffs == no changes | |||||
| with requests_mock() as mock: | |||||
| base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ | |||||
| '/records' | |||||
| with open('tests/fixtures/gandi-no-changes.json') as fh: | |||||
| mock.get(base, text=fh.read()) | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider.populate(zone) | |||||
| self.assertEquals(14, len(zone.records)) | |||||
| changes = self.expected.changes(zone, provider) | |||||
| self.assertEquals(0, len(changes)) | |||||
| del provider._zone_records[zone.name] | |||||
| # Default Gandi zone file. | |||||
| with requests_mock() as mock: | |||||
| base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ | |||||
| '/records' | |||||
| with open('tests/fixtures/gandi-records.json') as fh: | |||||
| mock.get(base, text=fh.read()) | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider.populate(zone) | |||||
| self.assertEquals(11, len(zone.records)) | |||||
| changes = self.expected.changes(zone, provider) | |||||
| self.assertEquals(24, len(changes)) | |||||
| # 2nd populate makes no network calls/all from cache | |||||
| again = Zone('unit.tests.', []) | |||||
| provider.populate(again) | |||||
| self.assertEquals(11, len(again.records)) | |||||
| # bust the cache | |||||
| del provider._zone_records[zone.name] | |||||
| def test_apply(self): | |||||
| provider = GandiProvider('test_id', 'token') | |||||
| # Zone does not exists but can be created. | |||||
| with requests_mock() as mock: | |||||
| mock.get(ANY, status_code=404, | |||||
| text='{"code": 404, "message": "The resource could not ' | |||||
| 'be found.", "object": "HTTPNotFound", "cause": ' | |||||
| '"Not Found"}') | |||||
| mock.post(ANY, status_code=201, | |||||
| text='{"message": "Domain Created"}') | |||||
| plan = provider.plan(self.expected) | |||||
| provider.apply(plan) | |||||
| # Zone does not exists and can't be created. | |||||
| with requests_mock() as mock: | |||||
| mock.get(ANY, status_code=404, | |||||
| text='{"code": 404, "message": "The resource could not ' | |||||
| 'be found.", "object": "HTTPNotFound", "cause": ' | |||||
| '"Not Found"}') | |||||
| mock.post(ANY, status_code=404, | |||||
| text='{"code": 404, "message": "The resource could not ' | |||||
| 'be found.", "object": "HTTPNotFound", "cause": ' | |||||
| '"Not Found"}') | |||||
| with self.assertRaises((GandiClientNotFound, | |||||
| GandiClientUnknownDomainName)) as ctx: | |||||
| plan = provider.plan(self.expected) | |||||
| provider.apply(plan) | |||||
| self.assertIn('This domain is not registred at Gandi.', | |||||
| text_type(ctx.exception)) | |||||
| resp = Mock() | |||||
| resp.json = Mock() | |||||
| provider._client._request = Mock(return_value=resp) | |||||
| with open('tests/fixtures/gandi-zone.json') as fh: | |||||
| zone = fh.read() | |||||
| # non-existent domain | |||||
| resp.json.side_effect = [ | |||||
| GandiClientNotFound(resp), # no zone in populate | |||||
| GandiClientNotFound(resp), # no domain during apply | |||||
| zone | |||||
| ] | |||||
| plan = provider.plan(self.expected) | |||||
| # No root NS, no ignored, no excluded | |||||
| n = len(self.expected.records) - 4 | |||||
| self.assertEquals(n, len(plan.changes)) | |||||
| self.assertEquals(n, provider.apply(plan)) | |||||
| self.assertFalse(plan.exists) | |||||
| provider._client._request.assert_has_calls([ | |||||
| call('GET', '/livedns/domains/unit.tests/records'), | |||||
| call('GET', '/livedns/domains/unit.tests'), | |||||
| call('POST', '/livedns/domains', data={ | |||||
| 'fqdn': 'unit.tests', | |||||
| 'zone': {} | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'www.sub', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'A', | |||||
| 'rrset_values': ['2.2.3.6'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'www', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'A', | |||||
| 'rrset_values': ['2.2.3.6'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'txt', | |||||
| 'rrset_ttl': 600, | |||||
| 'rrset_type': 'TXT', | |||||
| 'rrset_values': [ | |||||
| 'Bah bah black sheep', | |||||
| 'have you any wool.', | |||||
| 'v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string' | |||||
| '+with+numb3rs' | |||||
| ] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'spf', | |||||
| 'rrset_ttl': 600, | |||||
| 'rrset_type': 'SPF', | |||||
| 'rrset_values': ['v=spf1 ip4:192.168.0.1/16-all'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'ptr', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'PTR', | |||||
| 'rrset_values': ['foo.bar.com.'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'mx', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'MX', | |||||
| 'rrset_values': [ | |||||
| '10 smtp-4.unit.tests.', | |||||
| '20 smtp-2.unit.tests.', | |||||
| '30 smtp-3.unit.tests.', | |||||
| '40 smtp-1.unit.tests.' | |||||
| ] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'excluded', | |||||
| 'rrset_ttl': 3600, | |||||
| 'rrset_type': 'CNAME', | |||||
| 'rrset_values': ['unit.tests.'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'dname', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'DNAME', | |||||
| 'rrset_values': ['unit.tests.'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'cname', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'CNAME', | |||||
| 'rrset_values': ['unit.tests.'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'aaaa', | |||||
| 'rrset_ttl': 600, | |||||
| 'rrset_type': 'AAAA', | |||||
| 'rrset_values': ['2601:644:500:e210:62f8:1dff:feb8:947a'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': '_srv._tcp', | |||||
| 'rrset_ttl': 600, | |||||
| 'rrset_type': 'SRV', | |||||
| 'rrset_values': [ | |||||
| '10 20 30 foo-1.unit.tests.', | |||||
| '12 20 30 foo-2.unit.tests.' | |||||
| ] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': '@', | |||||
| 'rrset_ttl': 3600, | |||||
| 'rrset_type': 'SSHFP', | |||||
| 'rrset_values': [ | |||||
| '1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49', | |||||
| '1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73' | |||||
| ] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': '@', | |||||
| 'rrset_ttl': 3600, | |||||
| 'rrset_type': 'CAA', | |||||
| 'rrset_values': ['0 issue "ca.unit.tests"'] | |||||
| }), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': '@', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'A', | |||||
| 'rrset_values': ['1.2.3.4', '1.2.3.5'] | |||||
| }) | |||||
| ]) | |||||
| # expected number of total calls | |||||
| self.assertEquals(17, provider._client._request.call_count) | |||||
| provider._client._request.reset_mock() | |||||
| # delete 1 and update 1 | |||||
| provider._client.zone_records = Mock(return_value=[ | |||||
| { | |||||
| 'rrset_name': 'www', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'A', | |||||
| 'rrset_values': ['1.2.3.4'] | |||||
| }, | |||||
| { | |||||
| 'rrset_name': 'www', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'A', | |||||
| 'rrset_values': ['2.2.3.4'] | |||||
| }, | |||||
| { | |||||
| 'rrset_name': 'ttl', | |||||
| 'rrset_ttl': 600, | |||||
| 'rrset_type': 'A', | |||||
| 'rrset_values': ['3.2.3.4'] | |||||
| } | |||||
| ]) | |||||
| # Domain exists, we don't care about return | |||||
| resp.json.side_effect = ['{}'] | |||||
| wanted = Zone('unit.tests.', []) | |||||
| wanted.add_record(Record.new(wanted, 'ttl', { | |||||
| 'ttl': 300, | |||||
| 'type': 'A', | |||||
| 'value': '3.2.3.4' | |||||
| })) | |||||
| plan = provider.plan(wanted) | |||||
| self.assertTrue(plan.exists) | |||||
| self.assertEquals(2, len(plan.changes)) | |||||
| self.assertEquals(2, provider.apply(plan)) | |||||
| # recreate for update, and deletes for the 2 parts of the other | |||||
| provider._client._request.assert_has_calls([ | |||||
| call('DELETE', '/livedns/domains/unit.tests/records/www/A'), | |||||
| call('DELETE', '/livedns/domains/unit.tests/records/ttl/A'), | |||||
| call('POST', '/livedns/domains/unit.tests/records', data={ | |||||
| 'rrset_name': 'ttl', | |||||
| 'rrset_ttl': 300, | |||||
| 'rrset_type': 'A', | |||||
| 'rrset_values': ['3.2.3.4'] | |||||
| }) | |||||
| ], any_order=True) | |||||