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)