From fd136b42d1583a1376103967af644ed9a9ec627e Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Sun, 25 Oct 2020 01:08:08 +0200 Subject: [PATCH 01/10] Add support for Gandi LiveDNS --- octodns/provider/gandi.py | 334 +++++++++++++++++++++++++ tests/fixtures/gandi-default-zone.json | 93 +++++++ tests/fixtures/gandi-no-changes.json | 127 ++++++++++ tests/test_octodns_provider_gandi.py | 312 +++++++++++++++++++++++ 4 files changed, 866 insertions(+) create mode 100644 octodns/provider/gandi.py create mode 100644 tests/fixtures/gandi-default-zone.json create mode 100644 tests/fixtures/gandi-no-changes.json create mode 100644 tests/test_octodns_provider_gandi.py diff --git a/octodns/provider/gandi.py b/octodns/provider/gandi.py new file mode 100644 index 0000000..e11e9a3 --- /dev/null +++ b/octodns/provider/gandi.py @@ -0,0 +1,334 @@ +# +# +# + +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 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_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'] = '' + + 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 + self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, + len(changes)) + + # 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) diff --git a/tests/fixtures/gandi-default-zone.json b/tests/fixtures/gandi-default-zone.json new file mode 100644 index 0000000..deb4cb8 --- /dev/null +++ b/tests/fixtures/gandi-default-zone.json @@ -0,0 +1,93 @@ +[ + { + "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." + ] + } +] diff --git a/tests/fixtures/gandi-no-changes.json b/tests/fixtures/gandi-no-changes.json new file mode 100644 index 0000000..9bff3cb --- /dev/null +++ b/tests/fixtures/gandi-no-changes.json @@ -0,0 +1,127 @@ +[ + { + "rrset_type": "A", + "rrset_ttl": 300, + "rrset_name": "", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/records/cname/CNAME", + "rrset_values": [ + "unit.tests." + ] + }, + { + "rrset_type": "CNAME", + "rrset_ttl": 3600, + "rrset_name": "excluded", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/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/reductioncarbone.fr/records/_srv._tcp/SRV", + "rrset_values": [ + "10 20 30 foo-1.unit.tests.", + "12 20 30 foo-2.unit.tests." + ] + } + ] diff --git a/tests/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py new file mode 100644 index 0000000..8152fcf --- /dev/null +++ b/tests/test_octodns_provider_gandi.py @@ -0,0 +1,312 @@ +# +# +# + +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 +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)) + + # 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) + + # Non-existent zone doesn't populate anything + with requests_mock() as mock: + mock.get(ANY, status_code=404, + text='{"message": "Domain `foo.bar` not found"}') + + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(set(), zone.records) + + # 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(13, 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-default-zone.json') as fh: + mock.get(base, text=fh.read()) + + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(10, len(zone.records)) + changes = self.expected.changes(zone, provider) + self.assertEquals(22, len(changes)) + + # 2nd populate makes no network calls/all from cache + again = Zone('unit.tests.', []) + provider.populate(again) + self.assertEquals(10, len(again.records)) + + # bust the cache + del provider._zone_records[zone.name] + + def test_apply(self): + provider = GandiProvider('test_id', 'token') + + resp = Mock() + resp.json = Mock() + provider._client._request = Mock(return_value=resp) + + # non-existent domain + resp.json.side_effect = [ + GandiClientNotFound(resp), # no zone in populate + GandiClientNotFound(resp), # no domain during apply + ] + 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('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': '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(14, 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) From 3f852442648e6e4b2971ebf1cddea5ad53fce103 Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Mon, 26 Oct 2020 20:30:15 +0100 Subject: [PATCH 02/10] Fixes incorrect domain name in gandi-no-changes.json --- tests/fixtures/gandi-no-changes.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/fixtures/gandi-no-changes.json b/tests/fixtures/gandi-no-changes.json index 9bff3cb..4646327 100644 --- a/tests/fixtures/gandi-no-changes.json +++ b/tests/fixtures/gandi-no-changes.json @@ -3,7 +3,7 @@ "rrset_type": "A", "rrset_ttl": 300, "rrset_name": "", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/%40/A", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", "rrset_values": [ "1.2.3.4", "1.2.3.5" @@ -13,7 +13,7 @@ "rrset_type": "CAA", "rrset_ttl": 3600, "rrset_name": "", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/%40/CAA", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/CAA", "rrset_values": [ "0 issue \"ca.unit.tests\"" ] @@ -22,7 +22,7 @@ "rrset_type": "SSHFP", "rrset_ttl": 3600, "rrset_name": "", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/%40/SSHFP", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/SSHFP", "rrset_values": [ "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49", "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73" @@ -32,7 +32,7 @@ "rrset_type": "AAAA", "rrset_ttl": 600, "rrset_name": "aaaa", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/aaaa/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" ] @@ -41,7 +41,7 @@ "rrset_type": "CNAME", "rrset_ttl": 300, "rrset_name": "cname", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/cname/CNAME", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/cname/CNAME", "rrset_values": [ "unit.tests." ] @@ -50,7 +50,7 @@ "rrset_type": "CNAME", "rrset_ttl": 3600, "rrset_name": "excluded", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/excluded/CNAME", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/excluded/CNAME", "rrset_values": [ "unit.tests." ] @@ -59,7 +59,7 @@ "rrset_type": "MX", "rrset_ttl": 300, "rrset_name": "mx", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/mx/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.", @@ -71,7 +71,7 @@ "rrset_type": "PTR", "rrset_ttl": 300, "rrset_name": "ptr", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/ptr/PTR", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/ptr/PTR", "rrset_values": [ "foo.bar.com." ] @@ -80,7 +80,7 @@ "rrset_type": "SPF", "rrset_ttl": 600, "rrset_name": "spf", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/spf/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\"" ] @@ -89,7 +89,7 @@ "rrset_type": "TXT", "rrset_ttl": 600, "rrset_name": "txt", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/txt/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.\"", @@ -100,7 +100,7 @@ "rrset_type": "A", "rrset_ttl": 300, "rrset_name": "www", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/www/A", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/A", "rrset_values": [ "2.2.3.6" ] @@ -109,7 +109,7 @@ "rrset_type": "A", "rrset_ttl": 300, "rrset_name": "www.sub", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/www.sub/A", + "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www.sub/A", "rrset_values": [ "2.2.3.6" ] @@ -118,7 +118,7 @@ "rrset_type": "SRV", "rrset_ttl": 600, "rrset_name": "_srv._tcp", - "rrset_href": "https://api.gandi.net/v5/livedns/domains/reductioncarbone.fr/records/_srv._tcp/SRV", + "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." From bfaafeb61b92d284e835367c9de296035fcdb489 Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Mon, 26 Oct 2020 23:10:36 +0100 Subject: [PATCH 03/10] Fixes value of "rrset_name" parameter for domain APEX --- tests/fixtures/gandi-default-zone.json | 6 +++--- tests/fixtures/gandi-no-changes.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/fixtures/gandi-default-zone.json b/tests/fixtures/gandi-default-zone.json index deb4cb8..254b7c1 100644 --- a/tests/fixtures/gandi-default-zone.json +++ b/tests/fixtures/gandi-default-zone.json @@ -2,7 +2,7 @@ { "rrset_type": "A", "rrset_ttl": 10800, - "rrset_name": "", + "rrset_name": "@", "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", "rrset_values": [ "217.70.184.38" @@ -11,7 +11,7 @@ { "rrset_type": "MX", "rrset_ttl": 10800, - "rrset_name": "", + "rrset_name": "@", "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/MX", "rrset_values": [ "10 spool.mail.gandi.net.", @@ -21,7 +21,7 @@ { "rrset_type": "TXT", "rrset_ttl": 10800, - "rrset_name": "", + "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\"" diff --git a/tests/fixtures/gandi-no-changes.json b/tests/fixtures/gandi-no-changes.json index 4646327..1154628 100644 --- a/tests/fixtures/gandi-no-changes.json +++ b/tests/fixtures/gandi-no-changes.json @@ -2,7 +2,7 @@ { "rrset_type": "A", "rrset_ttl": 300, - "rrset_name": "", + "rrset_name": "@", "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A", "rrset_values": [ "1.2.3.4", @@ -12,7 +12,7 @@ { "rrset_type": "CAA", "rrset_ttl": 3600, - "rrset_name": "", + "rrset_name": "@", "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/CAA", "rrset_values": [ "0 issue \"ca.unit.tests\"" @@ -21,7 +21,7 @@ { "rrset_type": "SSHFP", "rrset_ttl": 3600, - "rrset_name": "", + "rrset_name": "@", "rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/SSHFP", "rrset_values": [ "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49", From 7161baa2628fd28d3eb2c9ceec6953a9afae359b Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Mon, 26 Oct 2020 23:23:32 +0100 Subject: [PATCH 04/10] Fixes code coverage for unsupported records types --- tests/fixtures/gandi-default-zone.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/fixtures/gandi-default-zone.json b/tests/fixtures/gandi-default-zone.json index 254b7c1..9b07b8e 100644 --- a/tests/fixtures/gandi-default-zone.json +++ b/tests/fixtures/gandi-default-zone.json @@ -89,5 +89,14 @@ "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" + ] } ] From 6d17b4671ab964d1dada7319e77f4de12438de02 Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Tue, 27 Oct 2020 11:23:22 +0100 Subject: [PATCH 05/10] Handle domains not registred at Gandi or not using Gandi's DNS --- octodns/provider/gandi.py | 32 ++++++++++++++++++++++++++++ tests/fixtures/gandi-zone.json | 7 ++++++ tests/test_octodns_provider_gandi.py | 32 +++++++++++++++++++--------- 3 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/gandi-zone.json diff --git a/octodns/provider/gandi.py b/octodns/provider/gandi.py index e11e9a3..1f89a80 100644 --- a/octodns/provider/gandi.py +++ b/octodns/provider/gandi.py @@ -41,6 +41,12 @@ class GandiClientNotFound(GandiClientException): 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): @@ -63,6 +69,16 @@ class GandiClient(object): 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() @@ -318,9 +334,25 @@ class GandiProvider(BaseProvider): 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: + raise GandiClientUnknownDomainName('This domain is not ' + 'registred at Gandi. ' + 'Please register or ' + 'transfer it here ' + 'to be able to manage its ' + 'DNS zone.') + # 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. diff --git a/tests/fixtures/gandi-zone.json b/tests/fixtures/gandi-zone.json new file mode 100644 index 0000000..e132f4c --- /dev/null +++ b/tests/fixtures/gandi-zone.json @@ -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" +} \ No newline at end of file diff --git a/tests/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py index 8152fcf..7448666 100644 --- a/tests/test_octodns_provider_gandi.py +++ b/tests/test_octodns_provider_gandi.py @@ -86,6 +86,18 @@ class TestGandiProvider(TestCase): 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') @@ -95,15 +107,6 @@ class TestGandiProvider(TestCase): provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) - # Non-existent zone doesn't populate anything - with requests_mock() as mock: - mock.get(ANY, status_code=404, - text='{"message": "Domain `foo.bar` not found"}') - - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(set(), zone.records) - # No diffs == no changes with requests_mock() as mock: base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ @@ -147,10 +150,14 @@ class TestGandiProvider(TestCase): 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) @@ -162,6 +169,11 @@ class TestGandiProvider(TestCase): 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, @@ -258,7 +270,7 @@ class TestGandiProvider(TestCase): }) ]) # expected number of total calls - self.assertEquals(14, provider._client._request.call_count) + self.assertEquals(16, provider._client._request.call_count) provider._client._request.reset_mock() From b280449969c5af0fe31eee1c8139e0995e54892f Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Tue, 27 Oct 2020 11:25:55 +0100 Subject: [PATCH 06/10] Add record targets normalizaltion --- octodns/provider/gandi.py | 8 ++++++++ .../{gandi-default-zone.json => gandi-records.json} | 9 +++++++++ tests/test_octodns_provider_gandi.py | 8 ++++---- 3 files changed, 21 insertions(+), 4 deletions(-) rename tests/fixtures/{gandi-default-zone.json => gandi-records.json} (92%) diff --git a/octodns/provider/gandi.py b/octodns/provider/gandi.py index 1f89a80..dcc222d 100644 --- a/octodns/provider/gandi.py +++ b/octodns/provider/gandi.py @@ -87,6 +87,14 @@ class GandiClient(object): 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): diff --git a/tests/fixtures/gandi-default-zone.json b/tests/fixtures/gandi-records.json similarity index 92% rename from tests/fixtures/gandi-default-zone.json rename to tests/fixtures/gandi-records.json index 9b07b8e..01d30f7 100644 --- a/tests/fixtures/gandi-default-zone.json +++ b/tests/fixtures/gandi-records.json @@ -98,5 +98,14 @@ "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/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py index 7448666..a818919 100644 --- a/tests/test_octodns_provider_gandi.py +++ b/tests/test_octodns_provider_gandi.py @@ -126,19 +126,19 @@ class TestGandiProvider(TestCase): with requests_mock() as mock: base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ '/records' - with open('tests/fixtures/gandi-default-zone.json') as fh: + with open('tests/fixtures/gandi-records.json') as fh: mock.get(base, text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) - self.assertEquals(10, len(zone.records)) + self.assertEquals(11, len(zone.records)) changes = self.expected.changes(zone, provider) - self.assertEquals(22, len(changes)) + self.assertEquals(23, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) - self.assertEquals(10, len(again.records)) + self.assertEquals(11, len(again.records)) # bust the cache del provider._zone_records[zone.name] From dc9dc45ae638e3ae5384ef4583c722e2b4efdbc9 Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Mon, 2 Nov 2020 18:00:48 +0100 Subject: [PATCH 07/10] Fixes tests after merging of #620 --- tests/fixtures/gandi-no-changes.json | 9 +++++++++ tests/test_octodns_provider_gandi.py | 12 +++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/gandi-no-changes.json b/tests/fixtures/gandi-no-changes.json index 1154628..b018785 100644 --- a/tests/fixtures/gandi-no-changes.json +++ b/tests/fixtures/gandi-no-changes.json @@ -46,6 +46,15 @@ "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, diff --git a/tests/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py index a818919..3cee392 100644 --- a/tests/test_octodns_provider_gandi.py +++ b/tests/test_octodns_provider_gandi.py @@ -116,7 +116,7 @@ class TestGandiProvider(TestCase): zone = Zone('unit.tests.', []) provider.populate(zone) - self.assertEquals(13, len(zone.records)) + self.assertEquals(14, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) @@ -133,7 +133,7 @@ class TestGandiProvider(TestCase): provider.populate(zone) self.assertEquals(11, len(zone.records)) changes = self.expected.changes(zone, provider) - self.assertEquals(23, len(changes)) + self.assertEquals(24, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) @@ -226,6 +226,12 @@ class TestGandiProvider(TestCase): '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, @@ -270,7 +276,7 @@ class TestGandiProvider(TestCase): }) ]) # expected number of total calls - self.assertEquals(16, provider._client._request.call_count) + self.assertEquals(17, provider._client._request.call_count) provider._client._request.reset_mock() From 05ce1344546c3f959912dedeaf5231d894543ef2 Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Mon, 2 Nov 2020 18:02:15 +0100 Subject: [PATCH 08/10] Add tests for zone creation --- tests/test_octodns_provider_gandi.py | 33 +++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py index 3cee392..5871cc9 100644 --- a/tests/test_octodns_provider_gandi.py +++ b/tests/test_octodns_provider_gandi.py @@ -14,7 +14,8 @@ from unittest import TestCase from octodns.record import Record from octodns.provider.gandi import GandiProvider, GandiClientBadRequest, \ - GandiClientUnauthorized, GandiClientForbidden, GandiClientNotFound + GandiClientUnauthorized, GandiClientForbidden, GandiClientNotFound, \ + GandiClientUnknownDomainName from octodns.provider.yaml import YamlProvider from octodns.zone import Zone @@ -146,6 +147,36 @@ class TestGandiProvider(TestCase): 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) From 6ebe0858811664e612bbc8eb66aa779bf79048ea Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Mon, 2 Nov 2020 18:04:45 +0100 Subject: [PATCH 09/10] Add GandiProvider to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 23ac0e8..6c0982e 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ The above command pulled the existing data out of Route53 and placed the results | [EasyDNSProvider](/octodns/provider/easydns.py) | | A, AAAA, CAA, CNAME, MX, NAPTR, NS, SRV, TXT | No | | | [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 | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | | | [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | Missing `NA` geo target | From f3e3f19cd3ac8d7910072bda89887e1d6c2d6459 Mon Sep 17 00:00:00 2001 From: Jonathan Leroy Date: Tue, 3 Nov 2020 22:59:39 +0100 Subject: [PATCH 10/10] Suppress previous exceptions before raising GandiClientUnknownDomainName exception --- octodns/provider/gandi.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/octodns/provider/gandi.py b/octodns/provider/gandi.py index dcc222d..84ff291 100644 --- a/octodns/provider/gandi.py +++ b/octodns/provider/gandi.py @@ -354,12 +354,16 @@ class GandiProvider(BaseProvider): self._client.zone_create(zone) self.log.info('_apply: zone has been successfully created') except GandiClientNotFound: - raise GandiClientUnknownDomainName('This domain is not ' - 'registred at Gandi. ' - 'Please register or ' - 'transfer it here ' - 'to be able to manage its ' - 'DNS zone.') + # 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