diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4a8a4..85afb0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ * [DnsMadeEasyProvider](https://github.com/octodns/octodns-dnsmadeeasy/) * [DynProvider](https://github.com/octodns/octodns-dynprovider/) * [EasyDnsProvider](https://github.com/octodns/octodns-easydns/) + * [GandiProvider](https://github.com/octodns/octodns-gandi/) * [Ns1Provider](https://github.com/octodns/octodns-ns1/) * [PowerDnsProvider](https://github.com/octodns/octodns-powerdns/) * [Route53Provider](https://github.com/octodns/octodns-route53/) also diff --git a/README.md b/README.md index 388f0fd..56bf8df 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ The table below lists the providers octoDNS supports. We're currently in the pro | [EasyDnsProvider](https://github.com/octodns/octodns-easydns/) | [octodns_easydns](https://github.com/octodns/octodns-easydns/) | | | | | | [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | | A, AAAA, ALIAS, CNAME | No | | | [EnvVarSource](/octodns/source/envvar.py) | | | TXT | No | read-only environment variable injection | -| [GandiProvider](/octodns/provider/gandi.py) | | | A, AAAA, ALIAS, CAA, CNAME, DNAME, MX, NS, PTR, SPF, SRV, SSHFP, TXT | No | | +| [GandiProvider](https://github.com/octodns/octodns-gandi/) | [octodns_gandi](https://github.com/octodns/octodns-gandi/) | | | | | | [GCoreProvider](/octodns/provider/gcore.py) | | | A, AAAA, NS, MX, TXT, SRV, CNAME, PTR | Dynamic | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [HetznerProvider](/octodns/provider/hetzner.py) | | | A, AAAA, CAA, CNAME, MX, NS, SRV, TXT | No | | diff --git a/octodns/provider/gandi.py b/octodns/provider/gandi.py index b1c4082..70523e8 100644 --- a/octodns/provider/gandi.py +++ b/octodns/provider/gandi.py @@ -5,373 +5,19 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from collections import defaultdict -from requests import Session -import logging - -from ..record import Record -from . import ProviderException -from .base import BaseProvider - - -class GandiClientException(ProviderException): - pass - - -class GandiClientBadRequest(GandiClientException): - - def __init__(self, r): - super(GandiClientBadRequest, self).__init__(r.text) - - -class GandiClientUnauthorized(GandiClientException): - - def __init__(self, r): - super(GandiClientUnauthorized, self).__init__(r.text) - - -class GandiClientForbidden(GandiClientException): - - def __init__(self, r): - super(GandiClientForbidden, self).__init__(r.text) - - -class GandiClientNotFound(GandiClientException): - - def __init__(self, r): - super(GandiClientNotFound, self).__init__(r.text) - - -class GandiClientUnknownDomainName(GandiClientException): - - def __init__(self, msg): - super(GandiClientUnknownDomainName, self).__init__(msg) - - -class GandiClient(object): - - def __init__(self, token): - session = Session() - session.headers.update({'Authorization': f'Apikey {token}'}) - self._session = session - self.endpoint = 'https://api.gandi.net/v5' - - def _request(self, method, path, params={}, data=None): - url = f'{self.endpoint}{path}' - r = self._session.request(method, url, params=params, json=data) - if r.status_code == 400: - raise GandiClientBadRequest(r) - if r.status_code == 401: - raise GandiClientUnauthorized(r) - elif r.status_code == 403: - raise GandiClientForbidden(r) - elif r.status_code == 404: - raise GandiClientNotFound(r) - r.raise_for_status() - return r - - def zone(self, zone_name): - return self._request('GET', f'/livedns/domains/{zone_name}').json() - - def zone_create(self, zone_name): - return self._request('POST', '/livedns/domains', data={ - 'fqdn': zone_name, - 'zone': {} - }).json() - - def zone_records(self, zone_name): - records = self._request('GET', - f'/livedns/domains/{zone_name}/records').json() - - for record in records: - if record['rrset_name'] == '@': - record['rrset_name'] = '' - - # Change relative targets to absolute ones. - if record['rrset_type'] in ['ALIAS', 'CNAME', 'DNAME', 'MX', - 'NS', 'SRV']: - for i, value in enumerate(record['rrset_values']): - if not value.endswith('.'): - record['rrset_values'][i] = f'{value}.{zone_name}.' - - return records - - def record_create(self, zone_name, data): - self._request('POST', f'/livedns/domains/{zone_name}/records', - data=data) - - def record_delete(self, zone_name, record_name, record_type): - self._request('DELETE', f'/livedns/domains/{zone_name}/records/' - f'{record_name}/{record_type}') - - -class GandiProvider(BaseProvider): - ''' - Gandi provider using API v5. - - gandi: - class: octodns.provider.gandi.GandiProvider - # Your API key (required) - token: XXXXXXXXXXXX - ''' - - SUPPORTS_GEO = False - SUPPORTS_DYNAMIC = False - SUPPORTS = set((['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', - 'MX', 'NS', 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT'])) - - def __init__(self, id, token, *args, **kwargs): - self.log = logging.getLogger(f'GandiProvider[{id}]') - self.log.debug('__init__: id=%s, token=***', id) - super(GandiProvider, self).__init__(id, *args, **kwargs) - self._client = GandiClient(token) - - self._zone_records = {} - - def _data_for_multiple(self, _type, records): - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': [v.replace(';', '\\;') for v in - records[0]['rrset_values']] if _type == 'TXT' else - records[0]['rrset_values'] - } - - _data_for_A = _data_for_multiple - _data_for_AAAA = _data_for_multiple - _data_for_TXT = _data_for_multiple - _data_for_SPF = _data_for_multiple - _data_for_NS = _data_for_multiple - - def _data_for_CAA(self, _type, records): - values = [] - for record in records[0]['rrset_values']: - flags, tag, value = record.split(' ') - values.append({ - 'flags': flags, - 'tag': tag, - # Remove quotes around value. - 'value': value[1:-1], - }) - - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': values - } - - def _data_for_single(self, _type, records): - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'value': records[0]['rrset_values'][0] - } - - _data_for_ALIAS = _data_for_single - _data_for_CNAME = _data_for_single - _data_for_DNAME = _data_for_single - _data_for_PTR = _data_for_single - - def _data_for_MX(self, _type, records): - values = [] - for record in records[0]['rrset_values']: - priority, server = record.split(' ') - values.append({ - 'preference': priority, - 'exchange': server - }) - - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': values - } - - def _data_for_SRV(self, _type, records): - values = [] - for record in records[0]['rrset_values']: - priority, weight, port, target = record.split(' ', 3) - values.append({ - 'priority': priority, - 'weight': weight, - 'port': port, - 'target': target - }) - - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': values - } - - def _data_for_SSHFP(self, _type, records): - values = [] - for record in records[0]['rrset_values']: - algorithm, fingerprint_type, fingerprint = record.split(' ', 2) - values.append({ - 'algorithm': algorithm, - 'fingerprint': fingerprint, - 'fingerprint_type': fingerprint_type - }) - - return { - 'ttl': records[0]['rrset_ttl'], - 'type': _type, - 'values': values - } - - def zone_records(self, zone): - if zone.name not in self._zone_records: - try: - self._zone_records[zone.name] = \ - self._client.zone_records(zone.name[:-1]) - except GandiClientNotFound: - return [] - - return self._zone_records[zone.name] - - def populate(self, zone, target=False, lenient=False): - self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, - target, lenient) - - values = defaultdict(lambda: defaultdict(list)) - for record in self.zone_records(zone): - _type = record['rrset_type'] - if _type not in self.SUPPORTS: - continue - values[record['rrset_name']][record['rrset_type']].append(record) - - before = len(zone.records) - for name, types in values.items(): - for _type, records in types.items(): - data_for = getattr(self, f'_data_for_{_type}') - record = Record.new(zone, name, data_for(_type, records), - source=self, lenient=lenient) - zone.add_record(record, lenient=lenient) - - exists = zone.name in self._zone_records - self.log.info('populate: found %s records, exists=%s', - len(zone.records) - before, exists) - return exists - - def _record_name(self, name): - return name if name else '@' - - def _params_for_multiple(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [v.replace('\\;', ';') for v in - record.values] if record._type == 'TXT' - else record.values - } - - _params_for_A = _params_for_multiple - _params_for_AAAA = _params_for_multiple - _params_for_NS = _params_for_multiple - _params_for_TXT = _params_for_multiple - _params_for_SPF = _params_for_multiple - - def _params_for_CAA(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [f'{v.flags} {v.tag} "{v.value}"' - for v in record.values] - } - - def _params_for_single(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [record.value] - } - - _params_for_ALIAS = _params_for_single - _params_for_CNAME = _params_for_single - _params_for_DNAME = _params_for_single - _params_for_PTR = _params_for_single - - def _params_for_MX(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [f'{v.preference} {v.exchange}' - for v in record.values] - } - - def _params_for_SRV(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [f'{v.priority} {v.weight} {v.port} {v.target}' - for v in record.values] - } - - def _params_for_SSHFP(self, record): - return { - 'rrset_name': self._record_name(record.name), - 'rrset_ttl': record.ttl, - 'rrset_type': record._type, - 'rrset_values': [f'{v.algorithm} {v.fingerprint_type} ' - f'{v.fingerprint}' for v in record.values] - } - - def _apply_create(self, change): - new = change.new - data = getattr(self, f'_params_for_{new._type}')(new) - self._client.record_create(new.zone.name[:-1], data) - - def _apply_update(self, change): - self._apply_delete(change) - self._apply_create(change) - - def _apply_delete(self, change): - existing = change.existing - zone = existing.zone - self._client.record_delete(zone.name[:-1], - self._record_name(existing.name), - existing._type) - - def _apply(self, plan): - desired = plan.desired - changes = plan.changes - zone = desired.name[:-1] - self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, - len(changes)) - - try: - self._client.zone(zone) - except GandiClientNotFound: - self.log.info('_apply: no existing zone, trying to create it') - try: - self._client.zone_create(zone) - self.log.info('_apply: zone has been successfully created') - except GandiClientNotFound: - # We suppress existing exception before raising - # GandiClientUnknownDomainName. - e = GandiClientUnknownDomainName('This domain is not ' - 'registered at Gandi. ' - 'Please register or ' - 'transfer it here ' - 'to be able to manage its ' - 'DNS zone.') - e.__cause__ = None - raise e - - # Force records deletion to be done before creation in order to avoid - # "CNAME record must be the only record" error when an existing CNAME - # record is replaced by an A/AAAA record. - changes.reverse() - - for change in changes: - class_name = change.__class__.__name__ - getattr(self, f'_apply_{class_name.lower()}')(change) - - # Clear out the cache if any - self._zone_records.pop(desired.name, None) +from logging import getLogger + +logger = getLogger('Gandi') +try: + logger.warn('octodns_gandi shimmed. Update your provider class to ' + 'octodns_gandi.GandiProvider. ' + 'Shim will be removed in 1.0') + from octodns_gandi import GandiProvider + GandiProvider # pragma: no cover +except ModuleNotFoundError: + logger.exception('GandiProvider has been moved into a seperate module, ' + 'octodns_gandi is now required. Provider class should ' + 'be updated to octodns_gandi.GandiProvider. See ' + 'https://github.com/octodns/octodns/README.md#updating-' + 'to-use-extracted-providers for more information.') + raise diff --git a/tests/fixtures/gandi-no-changes.json b/tests/fixtures/gandi-no-changes.json deleted file mode 100644 index a67dc93..0000000 --- a/tests/fixtures/gandi-no-changes.json +++ /dev/null @@ -1,154 +0,0 @@ -[ - { - "rrset_type": "A", - "rrset_ttl": 300, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", - "rrset_values": [ - "1.2.3.4", - "1.2.3.5" - ] - }, - { - "rrset_type": "CAA", - "rrset_ttl": 3600, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/CAA", - "rrset_values": [ - "0 issue \"ca.unit.tests\"" - ] - }, - { - "rrset_type": "SSHFP", - "rrset_ttl": 3600, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/SSHFP", - "rrset_values": [ - "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49", - "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73" - ] - }, - { - "rrset_type": "AAAA", - "rrset_ttl": 600, - "rrset_name": "aaaa", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/aaaa/AAAA", - "rrset_values": [ - "2601:644:500:e210:62f8:1dff:feb8:947a" - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 300, - "rrset_name": "cname", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/cname/CNAME", - "rrset_values": [ - "unit.tests." - ] - }, - { - "rrset_type": "DNAME", - "rrset_ttl": 300, - "rrset_name": "dname", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/dname/DNAME", - "rrset_values": [ - "unit.tests." - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 3600, - "rrset_name": "excluded", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/excluded/CNAME", - "rrset_values": [ - "unit.tests." - ] - }, - { - "rrset_type": "MX", - "rrset_ttl": 300, - "rrset_name": "mx", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/mx/MX", - "rrset_values": [ - "10 smtp-4.unit.tests.", - "20 smtp-2.unit.tests.", - "30 smtp-3.unit.tests.", - "40 smtp-1.unit.tests." - ] - }, - { - "rrset_type": "PTR", - "rrset_ttl": 300, - "rrset_name": "ptr", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/ptr/PTR", - "rrset_values": [ - "foo.bar.com." - ] - }, - { - "rrset_type": "SPF", - "rrset_ttl": 600, - "rrset_name": "spf", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/spf/SPF", - "rrset_values": [ - "\"v=spf1 ip4:192.168.0.1/16-all\"" - ] - }, - { - "rrset_type": "TXT", - "rrset_ttl": 600, - "rrset_name": "txt", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/txt/TXT", - "rrset_values": [ - "\"Bah bah black sheep\"", - "\"have you any wool.\"", - "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"" - ] - }, - { - "rrset_type": "A", - "rrset_ttl": 300, - "rrset_name": "www", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/A", - "rrset_values": [ - "2.2.3.6" - ] - }, - { - "rrset_type": "A", - "rrset_ttl": 300, - "rrset_name": "www.sub", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www.sub/A", - "rrset_values": [ - "2.2.3.6" - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 600, - "rrset_name": "_imap._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV", - "rrset_values": [ - "0 0 0 ." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 600, - "rrset_name": "_pop3._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV", - "rrset_values": [ - "0 0 0 ." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 600, - "rrset_name": "_srv._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_srv._tcp/SRV", - "rrset_values": [ - "10 20 30 foo-1.unit.tests.", - "12 20 30 foo-2.unit.tests." - ] - } - ] diff --git a/tests/fixtures/gandi-records.json b/tests/fixtures/gandi-records.json deleted file mode 100644 index 01d30f7..0000000 --- a/tests/fixtures/gandi-records.json +++ /dev/null @@ -1,111 +0,0 @@ -[ - { - "rrset_type": "A", - "rrset_ttl": 10800, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", - "rrset_values": [ - "217.70.184.38" - ] - }, - { - "rrset_type": "MX", - "rrset_ttl": 10800, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/MX", - "rrset_values": [ - "10 spool.mail.gandi.net.", - "50 fb.mail.gandi.net." - ] - }, - { - "rrset_type": "TXT", - "rrset_ttl": 10800, - "rrset_name": "@", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/TXT", - "rrset_values": [ - "\"v=spf1 include:_mailcust.gandi.net ?all\"" - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 10800, - "rrset_name": "webmail", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/webmail/CNAME", - "rrset_values": [ - "webmail.gandi.net." - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 10800, - "rrset_name": "www", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/CNAME", - "rrset_values": [ - "webredir.vip.gandi.net." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_imap._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV", - "rrset_values": [ - "0 0 0 ." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_imaps._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imaps._tcp/SRV", - "rrset_values": [ - "0 1 993 mail.gandi.net." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_pop3._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV", - "rrset_values": [ - "0 0 0 ." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_pop3s._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3s._tcp/SRV", - "rrset_values": [ - "10 1 995 mail.gandi.net." - ] - }, - { - "rrset_type": "SRV", - "rrset_ttl": 10800, - "rrset_name": "_submission._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_submission._tcp/SRV", - "rrset_values": [ - "0 1 465 mail.gandi.net." - ] - }, - { - "rrset_type": "CDS", - "rrset_ttl": 10800, - "rrset_name": "sub", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/sub/CDS", - "rrset_values": [ - "32128 13 1 6823D9BB1B03DF714DD0EB163E20B341C96D18C0" - ] - }, - { - "rrset_type": "CNAME", - "rrset_ttl": 10800, - "rrset_name": "relative", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/relative/CNAME", - "rrset_values": [ - "target" - ] - } -] diff --git a/tests/fixtures/gandi-zone.json b/tests/fixtures/gandi-zone.json deleted file mode 100644 index e132f4c..0000000 --- a/tests/fixtures/gandi-zone.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain_keys_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/keys", - "fqdn": "unit.tests", - "automatic_snapshots": true, - "domain_records_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records", - "domain_href": "https://api.gandi.net/v5/livedns/domains/unit.tests" -} \ No newline at end of file diff --git a/tests/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py index 858389f..1e3df1e 100644 --- a/tests/test_octodns_provider_gandi.py +++ b/tests/test_octodns_provider_gandi.py @@ -5,372 +5,17 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from mock import Mock, call -from os.path import dirname, join -from requests import HTTPError -from requests_mock import ANY, mock as requests_mock from unittest import TestCase -from octodns.record import Record -from octodns.provider.gandi import GandiProvider, GandiClientBadRequest, \ - GandiClientUnauthorized, GandiClientForbidden, GandiClientNotFound, \ - GandiClientUnknownDomainName -from octodns.provider.yaml import YamlProvider -from octodns.zone import Zone +# Just for coverage +import octodns.provider.fastdns +# Quell warnings +octodns.provider.fastdns -class TestGandiProvider(TestCase): - expected = Zone('unit.tests.', []) - source = YamlProvider('test', join(dirname(__file__), 'config')) - source.populate(expected) +class TestGandiShim(TestCase): - # We remove this record from the test zone as Gandi API reject it - # (rightfully). - expected._remove_record(Record.new(expected, 'sub', { - 'ttl': 1800, - 'type': 'NS', - 'values': [ - '6.2.3.4.', - '7.2.3.4.' - ] - })) - - def test_populate(self): - - provider = GandiProvider('test_id', 'token') - - # 400 - Bad Request. - with requests_mock() as mock: - mock.get(ANY, status_code=400, - text='{"status": "error", "errors": [{"location": ' - '"body", "name": "items", "description": ' - '"\'6.2.3.4.\': invalid hostname (param: ' - '{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, ' - '\'rrset_name\': u\'sub\', \'rrset_values\': ' - '[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}, {"location": ' - '"body", "name": "items", "description": ' - '"\'7.2.3.4.\': invalid hostname (param: ' - '{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, ' - '\'rrset_name\': u\'sub\', \'rrset_values\': ' - '[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}]}') - - with self.assertRaises(GandiClientBadRequest) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertIn('"status": "error"', str(ctx.exception)) - - # 401 - Unauthorized. - with requests_mock() as mock: - mock.get(ANY, status_code=401, - text='{"code":401,"message":"The server could not verify ' - 'that you authorized to access the document you ' - 'requested. Either you supplied the wrong ' - 'credentials (e.g., bad api key), or your access ' - 'token has expired","object":"HTTPUnauthorized",' - '"cause":"Unauthorized"}') - - with self.assertRaises(GandiClientUnauthorized) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertIn('"cause":"Unauthorized"', str(ctx.exception)) - - # 403 - Forbidden. - with requests_mock() as mock: - mock.get(ANY, status_code=403, - text='{"code":403,"message":"Access was denied to this ' - 'resource.","object":"HTTPForbidden","cause":' - '"Forbidden"}') - - with self.assertRaises(GandiClientForbidden) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertIn('"cause":"Forbidden"', str(ctx.exception)) - - # 404 - Not Found. - with requests_mock() as mock: - mock.get(ANY, status_code=404, - text='{"code": 404, "message": "The resource could not ' - 'be found.", "object": "HTTPNotFound", "cause": ' - '"Not Found"}') - - with self.assertRaises(GandiClientNotFound) as ctx: - zone = Zone('unit.tests.', []) - provider._client.zone(zone) - self.assertIn('"cause": "Not Found"', str(ctx.exception)) - - # General error - with requests_mock() as mock: - mock.get(ANY, status_code=502, text='Things caught fire') - - with self.assertRaises(HTTPError) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(502, ctx.exception.response.status_code) - - # No diffs == no changes - with requests_mock() as mock: - base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ - '/records' - with open('tests/fixtures/gandi-no-changes.json') as fh: - mock.get(base, text=fh.read()) - - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(16, len(zone.records)) - changes = self.expected.changes(zone, provider) - self.assertEquals(0, len(changes)) - - del provider._zone_records[zone.name] - - # Default Gandi zone file. - with requests_mock() as mock: - base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ - '/records' - with open('tests/fixtures/gandi-records.json') as fh: - mock.get(base, text=fh.read()) - - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(11, len(zone.records)) - changes = self.expected.changes(zone, provider) - self.assertEquals(24, len(changes)) - - # 2nd populate makes no network calls/all from cache - again = Zone('unit.tests.', []) - provider.populate(again) - self.assertEquals(11, len(again.records)) - - # bust the cache - del provider._zone_records[zone.name] - - def test_apply(self): - provider = GandiProvider('test_id', 'token') - - # Zone does not exists but can be created. - with requests_mock() as mock: - mock.get(ANY, status_code=404, - text='{"code": 404, "message": "The resource could not ' - 'be found.", "object": "HTTPNotFound", "cause": ' - '"Not Found"}') - mock.post(ANY, status_code=201, - text='{"message": "Domain Created"}') - - plan = provider.plan(self.expected) - provider.apply(plan) - - # Zone does not exists and can't be created. - with requests_mock() as mock: - mock.get(ANY, status_code=404, - text='{"code": 404, "message": "The resource could not ' - 'be found.", "object": "HTTPNotFound", "cause": ' - '"Not Found"}') - mock.post(ANY, status_code=404, - text='{"code": 404, "message": "The resource could not ' - 'be found.", "object": "HTTPNotFound", "cause": ' - '"Not Found"}') - - with self.assertRaises((GandiClientNotFound, - GandiClientUnknownDomainName)) as ctx: - plan = provider.plan(self.expected) - provider.apply(plan) - self.assertIn('This domain is not registered at Gandi.', - str(ctx.exception)) - - resp = Mock() - resp.json = Mock() - provider._client._request = Mock(return_value=resp) - - with open('tests/fixtures/gandi-zone.json') as fh: - zone = fh.read() - - # non-existent domain - resp.json.side_effect = [ - GandiClientNotFound(resp), # no zone in populate - GandiClientNotFound(resp), # no domain during apply - zone - ] - plan = provider.plan(self.expected) - - # No root NS, no ignored, no excluded, no LOC - n = len(self.expected.records) - 6 - self.assertEquals(n, len(plan.changes)) - self.assertEquals(n, provider.apply(plan)) - self.assertFalse(plan.exists) - - provider._client._request.assert_has_calls([ - call('GET', '/livedns/domains/unit.tests/records'), - call('GET', '/livedns/domains/unit.tests'), - call('POST', '/livedns/domains', data={ - 'fqdn': 'unit.tests', - 'zone': {} - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'www.sub', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['2.2.3.6'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'www', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['2.2.3.6'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'txt', - 'rrset_ttl': 600, - 'rrset_type': 'TXT', - 'rrset_values': [ - 'Bah bah black sheep', - 'have you any wool.', - 'v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string' - '+with+numb3rs' - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'spf', - 'rrset_ttl': 600, - 'rrset_type': 'SPF', - 'rrset_values': ['v=spf1 ip4:192.168.0.1/16-all'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'ptr', - 'rrset_ttl': 300, - 'rrset_type': 'PTR', - 'rrset_values': ['foo.bar.com.'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'mx', - 'rrset_ttl': 300, - 'rrset_type': 'MX', - 'rrset_values': [ - '10 smtp-4.unit.tests.', - '20 smtp-2.unit.tests.', - '30 smtp-3.unit.tests.', - '40 smtp-1.unit.tests.' - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'excluded', - 'rrset_ttl': 3600, - 'rrset_type': 'CNAME', - 'rrset_values': ['unit.tests.'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'dname', - 'rrset_ttl': 300, - 'rrset_type': 'DNAME', - 'rrset_values': ['unit.tests.'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'cname', - 'rrset_ttl': 300, - 'rrset_type': 'CNAME', - 'rrset_values': ['unit.tests.'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'aaaa', - 'rrset_ttl': 600, - 'rrset_type': 'AAAA', - 'rrset_values': ['2601:644:500:e210:62f8:1dff:feb8:947a'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '_srv._tcp', - 'rrset_ttl': 600, - 'rrset_type': 'SRV', - 'rrset_values': [ - '10 20 30 foo-1.unit.tests.', - '12 20 30 foo-2.unit.tests.' - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '_pop3._tcp', - 'rrset_ttl': 600, - 'rrset_type': 'SRV', - 'rrset_values': [ - '0 0 0 .', - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '_imap._tcp', - 'rrset_ttl': 600, - 'rrset_type': 'SRV', - 'rrset_values': [ - '0 0 0 .', - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '@', - 'rrset_ttl': 3600, - 'rrset_type': 'SSHFP', - 'rrset_values': [ - '1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49', - '1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73' - ] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '@', - 'rrset_ttl': 3600, - 'rrset_type': 'CAA', - 'rrset_values': ['0 issue "ca.unit.tests"'] - }), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': '@', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['1.2.3.4', '1.2.3.5'] - }) - ]) - # expected number of total calls - self.assertEquals(19, provider._client._request.call_count) - - provider._client._request.reset_mock() - - # delete 1 and update 1 - provider._client.zone_records = Mock(return_value=[ - { - 'rrset_name': 'www', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['1.2.3.4'] - }, - { - 'rrset_name': 'www', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['2.2.3.4'] - }, - { - 'rrset_name': 'ttl', - 'rrset_ttl': 600, - 'rrset_type': 'A', - 'rrset_values': ['3.2.3.4'] - } - ]) - - # Domain exists, we don't care about return - resp.json.side_effect = ['{}'] - - wanted = Zone('unit.tests.', []) - wanted.add_record(Record.new(wanted, 'ttl', { - 'ttl': 300, - 'type': 'A', - 'value': '3.2.3.4' - })) - - plan = provider.plan(wanted) - self.assertTrue(plan.exists) - self.assertEquals(2, len(plan.changes)) - self.assertEquals(2, provider.apply(plan)) - - # recreate for update, and deletes for the 2 parts of the other - provider._client._request.assert_has_calls([ - call('DELETE', '/livedns/domains/unit.tests/records/www/A'), - call('DELETE', '/livedns/domains/unit.tests/records/ttl/A'), - call('POST', '/livedns/domains/unit.tests/records', data={ - 'rrset_name': 'ttl', - 'rrset_ttl': 300, - 'rrset_type': 'A', - 'rrset_values': ['3.2.3.4'] - }) - ], any_order=True) + def test_missing(self): + with self.assertRaises(ModuleNotFoundError): + from octodns.provider.gandi import GandiProvider + GandiProvider