diff --git a/octodns/provider/rackspace.py b/octodns/provider/rackspace.py index f6817ae..eef20d9 100644 --- a/octodns/provider/rackspace.py +++ b/octodns/provider/rackspace.py @@ -1,13 +1,10 @@ # # # - - from __future__ import absolute_import, division, print_function, \ unicode_literals from requests import HTTPError, Session, post -import json from collections import defaultdict import logging @@ -40,6 +37,10 @@ class RackspaceProvider(BaseProvider): sess.headers.update({'X-Auth-Token': auth_token}) self._sess = sess + # Map record type, name, and data to an id when populating so that + # we can find the id for update and delete operations. + self._id_map = {} + def _get_auth_token(self, username, api_key): ret = post('https://identity.api.rackspacecloud.com/v2.0/tokens', json={"auth": {"RAX-KSKEY:apiKeyCredentials": {"username": username, "apiKey": api_key}}}, @@ -91,9 +92,15 @@ class RackspaceProvider(BaseProvider): def _post(self, path, data=None): return self._request('POST', path, data=data) + def _put(self, path, data=None): + return self._request('PUT', path, data=data) + def _patch(self, path, data=None): return self._request('PATCH', path, data=data) + def _delete(self, path, data=None): + return self._request('DELETE', path, data=data) + def _data_for_multiple(self, rrset): # TODO: geo not supported return { @@ -204,7 +211,7 @@ class RackspaceProvider(BaseProvider): resp_data = None try: domain_id = self._get_zone_id_for(zone) - resp_data = self._request('GET', '/domains/{}/records'.format(domain_id), pagination_key='records') + resp_data = self._request('GET', 'domains/{}/records'.format(domain_id), pagination_key='records') self.log.debug('populate: loaded') except HTTPError as e: if e.response.status_code == 401: @@ -224,9 +231,9 @@ class RackspaceProvider(BaseProvider): if resp_data: records = self._group_records(resp_data) for record_type, records_of_type in records.items(): + if record_type == 'SOA': + continue for raw_record_name, record_set in records_of_type.items(): - if record_type == 'SOA': - continue data_for = getattr(self, '_data_for_{}'.format(record_type)) record_name = zone.hostname_from_fqdn(raw_record_name) record = Record.new(zone, record_name, data_for(record_set), @@ -239,6 +246,7 @@ class RackspaceProvider(BaseProvider): def _group_records(self, all_records): records = defaultdict(lambda: defaultdict(list)) for record in all_records: + self._id_map[(record['type'], record['name'], record['data'])] = record['id'] records[record['type']][record['name']].append(record) return records @@ -294,61 +302,45 @@ class RackspaceProvider(BaseProvider): } for v in record.values] def _mod_Create(self, change): - new = change.new - records_for = getattr(self, '_records_for_{}'.format(new._type)) - return { - 'name': new.fqdn, - 'type': new._type, - 'ttl': new.ttl, - 'changetype': 'REPLACE', - 'records': records_for(new) - } - - _mod_Update = _mod_Create + out = [] + for value in change.new.values: + out.append({ + 'name': change.new.fqdn, + 'type': change.new._type, + 'data': value, + 'ttl': change.new.ttl, + }) + return out + + def _mod_Update(self, change): + # A reduction in number of values in an update record needs + # to get upgraded into a Delete change for the removed values. + deleted_values = set(change.existing.values) - set(change.new.values) + delete_out = self._delete_given_change_values(change, deleted_values) + + update_out = [] + for value in change.new.values: + key = (change.existing._type, change.existing.fqdn, value) + rsid = self._id_map[key] + update_out.append({ + 'id': rsid, + 'name': change.new.fqdn, + 'data': value, + 'ttl': change.new.ttl, + }) + return update_out, delete_out def _mod_Delete(self, change): - existing = change.existing - records_for = getattr(self, '_records_for_{}'.format(existing._type)) - return { - 'name': existing.fqdn, - 'type': existing._type, - 'ttl': existing.ttl, - 'changetype': 'DELETE', - 'records': records_for(existing) - } + return self._delete_given_change_values(change, change.existing.values) - def _get_nameserver_record(self, existing): - return None - - def _extra_changes(self, existing, _): - self.log.debug('_extra_changes: zone=%s', existing.name) - - ns = self._get_nameserver_record(existing) - if not ns: - return [] - - # sorting mostly to make things deterministic for testing, but in - # theory it let us find what we're after quickier (though sorting would - # ve more exepensive.) - for record in sorted(existing.records): - if record == ns: - # We've found the top-level NS record, return any changes - change = record.changes(ns, self) - self.log.debug('_extra_changes: change=%s', change) - if change: - # We need to modify an existing record - return [change] - # No change is necessary - return [] - # No existing top-level NS - self.log.debug('_extra_changes: create') - return [Create(ns)] - - def _get_error(self, http_error): - try: - return http_error.response.json()['error'] - except Exception: - return '' + def _delete_given_change_values(self, change, values): + out = [] + for value in values: + key = (change.existing._type, change.existing.fqdn, value) + rsid = self._id_map[key] + out.append('id=' + rsid) + del self._id_map[key] + return out def _apply(self, plan): desired = plan.desired @@ -356,46 +348,29 @@ class RackspaceProvider(BaseProvider): self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, len(changes)) - mods = [] + domain_id = self._get_zone_id_for(desired) + creates = [] + updates = [] + deletes = [] for change in changes: - class_name = change.__class__.__name__ - mods.append(getattr(self, '_mod_{}'.format(class_name))(change)) - self.log.debug('_apply: sending change request') - - try: - self._patch('zones/{}'.format(desired.name), - data={'rrsets': mods}) - self.log.debug('_apply: patched') - except HTTPError as e: - error = self._get_error(e) - if e.response.status_code != 422 or \ - not error.startswith('Could not find domain '): - self.log.error('_apply: status=%d, text=%s', - e.response.status_code, - e.response.text) - raise - self.log.info('_apply: creating zone=%s', desired.name) - # 422 means powerdns doesn't know anything about the requsted - # domain. We'll try to create it with the correct records instead - # of update. Hopefully all the mods are creates :-) - data = { - 'name': desired.name, - 'kind': 'Master', - 'masters': [], - 'nameservers': [], - 'rrsets': mods, - 'soa_edit_api': 'INCEPTION-INCREMENT', - 'serial': 0, - } - try: - self._post('zones', data) - except HTTPError as e: - self.log.error('_apply: status=%d, text=%s', - e.response.status_code, - e.response.text) - raise - self.log.debug('_apply: created') - - self.log.debug('_apply: complete') - + if change.__class__.__name__ == 'Create': + creates += self._mod_Create(change) + elif change.__class__.__name__ == 'Update': + add_updates, add_deletes = self._mod_Update(change) + updates += add_updates + deletes += add_deletes + elif change.__class__.__name__ == 'Delete': + deletes += self._mod_Delete(change) + + if creates: + data = {"records": sorted(creates, key=lambda v: v['name'])} + self._post('domains/{}/records'.format(domain_id), data=data) + + if updates: + data = {"records": sorted(updates, key=lambda v: v['name'])} + self._put('domains/{}/records'.format(domain_id), data=data) + + if deletes: + params = "&".join(sorted(deletes)) + self._delete('domains/{}/records?{}'.format(domain_id, params)) diff --git a/tests/fixtures/rackspace-sample-recordset-existing-nameservers.json b/tests/fixtures/rackspace-sample-recordset-existing-nameservers.json new file mode 100644 index 0000000..034aa44 --- /dev/null +++ b/tests/fixtures/rackspace-sample-recordset-existing-nameservers.json @@ -0,0 +1,29 @@ +{ + "totalEntries" : 3, + "records" : [{ + "name" : "unit.tests.", + "id" : "A-6822995", + "type" : "A", + "data" : "1.2.3.4", + "updated" : "2011-06-24T01:12:53.000+0000", + "ttl" : 60, + "created" : "2011-06-24T01:12:53.000+0000" + }, { + "name" : "unit.tests.", + "id" : "NS-454454", + "type" : "NS", + "data" : "8.8.8.8.", + "updated" : "2011-06-24T01:12:51.000+0000", + "ttl" : 600, + "created" : "2011-06-24T01:12:51.000+0000" + }, { + "name" : "unit.tests.", + "id" : "NS-454455", + "type" : "NS", + "data" : "9.9.9.9.", + "updated" : "2011-06-24T01:12:52.000+0000", + "ttl" : 600, + "created" : "2011-06-24T01:12:52.000+0000" + }], + "links" : [] +} diff --git a/tests/test_octodns_source_rackspace.py b/tests/test_octodns_source_rackspace.py index 44ec1eb..4c53e95 100644 --- a/tests/test_octodns_source_rackspace.py +++ b/tests/test_octodns_source_rackspace.py @@ -5,10 +5,11 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals +import json import re -from json import loads, dumps from os.path import dirname, join from unittest import TestCase +from urlparse import urlparse from requests import HTTPError from requests_mock import ANY, mock as requests_mock @@ -18,9 +19,11 @@ from octodns.provider.yaml import YamlProvider from octodns.record import Record from octodns.zone import Zone +from pprint import pprint + EMPTY_TEXT = ''' { - "totalEntries" : 6, + "totalEntries" : 0, "records" : [] } ''' @@ -37,51 +40,66 @@ with open('./tests/fixtures/rackspace-sample-recordset-page1.json') as fh: with open('./tests/fixtures/rackspace-sample-recordset-page2.json') as fh: RECORDS_PAGE_2 = fh.read() -def load_provider(): - with requests_mock() as mock: - mock.post(ANY, status_code=200, text=AUTH_RESPONSE) - return RackspaceProvider('test', 'api-key') - - -class TestRackspaceSource(TestCase): +with open('./tests/fixtures/rackspace-sample-recordset-existing-nameservers.json') as fh: + RECORDS_EXISTING_NAMESERVERS = fh.read() - def test_provider(self): - provider = load_provider() +class TestRackspaceProvider(TestCase): + def setUp(self): + with requests_mock() as mock: + mock.post(ANY, status_code=200, text=AUTH_RESPONSE) + self.provider = RackspaceProvider('test', 'api-key') + self.assertTrue(mock.called_once) - # Bad auth + def test_bad_auth(self): with requests_mock() as mock: mock.get(ANY, status_code=401, text='Unauthorized') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) - provider.populate(zone) + self.provider.populate(zone) self.assertTrue('unauthorized' in ctx.exception.message) + self.assertTrue(mock.called_once) - # General error + def test_server_error(self): 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.provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) + self.assertTrue(mock.called_once) - # Non-existant zone doesn't populate anything + def test_nonexistent_zone(self): + # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=422, json={'error': "Could not find domain 'unit.tests.'"}) zone = Zone('unit.tests.', []) - provider.populate(zone) + self.provider.populate(zone) self.assertEquals(set(), zone.records) + self.assertTrue(mock.called_once) - # The rest of this is messy/complicated b/c it's dealing with mocking + def test_multipage_populate(self): + with requests_mock() as mock: + mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) + mock.get(re.compile('records'), status_code=200, text=RECORDS_PAGE_1) + mock.get(re.compile('records.*offset=3'), status_code=200, text=RECORDS_PAGE_2) + zone = Zone('unit.tests.', []) + self.provider.populate(zone) + self.assertEquals(5, len(zone.records)) + + def _load_full_config(self): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) - expected_n = len(expected.records) - 1 - self.assertEquals(14, expected_n) + self.assertEquals(15, len(expected.records)) + return expected + + def test_changes_are_formatted_correctly(self): + expected = self._load_full_config() # No diffs == no changes with requests_mock() as mock: @@ -90,27 +108,551 @@ class TestRackspaceSource(TestCase): mock.get(re.compile('records.*offset=3'), status_code=200, text=RECORDS_PAGE_2) zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(5, len(zone.records)) - changes = expected.changes(zone, provider) + self.provider.populate(zone) + changes = expected.changes(zone, self.provider) self.assertEquals(18, len(changes)) + def test_plan_disappearing_ns_records(self): + expected = Zone('unit.tests.', []) + expected.add_record(Record.new(expected, '', { + 'type': 'NS', + 'ttl': 600, + 'values': ['8.8.8.8.', '9.9.9.9.'] + })) + expected.add_record(Record.new(expected, 'sub', { + 'type': 'NS', + 'ttl': 600, + 'values': ['8.8.8.8.', '9.9.9.9.'] + })) + with requests_mock() as mock: + mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) + mock.get(re.compile('records'), status_code=200, text=EMPTY_TEXT) + + plan = self.provider.plan(expected) + self.assertTrue(mock.called) + + # OctoDNS does not propagate top-level NS records. + self.assertEquals(1, len(plan.changes)) + + def _test_apply_with_data(self, data): + expected = Zone('unit.tests.', []) + for record in data.OtherRecords: + expected.add_record(Record.new(expected, record['subdomain'], record['data'])) + + with requests_mock() as list_mock: + list_mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) + list_mock.get(re.compile('records'), status_code=200, json=data.OwnRecords) + plan = self.provider.plan(expected) + self.assertTrue(list_mock.called) + if not data.ExpectChanges: + self.assertFalse(plan) + return + + with requests_mock() as mock: + called = set() + + def make_assert_sending_right_body(expected): + def _assert_sending_right_body(request, _context): + called.add(request.method) + if request.method != 'DELETE': + self.assertEqual(request.headers['content-type'], 'application/json') + self.assertDictEqual(expected, json.loads(request.body)) + else: + parts = urlparse(request.url) + self.assertEqual(expected, parts.query) + return '' + return _assert_sending_right_body + + mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) + mock.post(re.compile('domains/.*/records$'), status_code=202, + text=make_assert_sending_right_body(data.ExpectedAdditions)) + mock.delete(re.compile('domains/.*/records?.*'), status_code=202, + text=make_assert_sending_right_body(data.ExpectedDeletions)) + mock.put(re.compile('domains/.*/records$'), status_code=202, + text=make_assert_sending_right_body(data.ExpectedUpdates)) + + self.provider.apply(plan) + self.assertTrue(data.ExpectedAdditions is None or "POST" in called) + self.assertTrue(data.ExpectedDeletions is None or "DELETE" in called) + self.assertTrue(data.ExpectedUpdates is None or "PUT" in called) + + def test_apply_no_change_empty(self): + class TestData(object): + OtherRecords = [] + OwnRecords = { + "totalEntries": 0, + "records": [] + } + ExpectChanges = False + ExpectedAdditions = None + ExpectedDeletions = None + ExpectedUpdates = None + return self._test_apply_with_data(TestData) + + def test_apply_no_change_a_records(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": '', + "data": { + 'type': 'A', + 'ttl': 60, + 'values': ['1.2.3.4', '1.2.3.5', '1.2.3.6'] + } + } + ] + OwnRecords = { + "totalEntries": 3, + "records": [{ + "name": "unit.tests.", + "id": "A-111111", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }, { + "name": "unit.tests.", + "id": "A-222222", + "type": "A", + "data": "1.2.3.5", + "ttl": 60 + }, { + "name": "unit.tests.", + "id": "A-333333", + "type": "A", + "data": "1.2.3.6", + "ttl": 60 + }] + } + ExpectChanges = False + ExpectedAdditions = None + ExpectedDeletions = None + ExpectedUpdates = None + return self._test_apply_with_data(TestData) + + def test_apply_no_change_a_records_cross_zone(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": 'foo', + "data": { + 'type': 'A', + 'ttl': 60, + 'value': '1.2.3.4' + } + }, + { + "subdomain": 'bar', + "data": { + 'type': 'A', + 'ttl': 60, + 'value': '1.2.3.4' + } + } + ] + OwnRecords = { + "totalEntries": 3, + "records": [{ + "name": "foo.unit.tests.", + "id": "A-111111", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }, { + "name": "bar.unit.tests.", + "id": "A-222222", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }] + } + ExpectChanges = False + ExpectedAdditions = None + ExpectedDeletions = None + ExpectedUpdates = None + return self._test_apply_with_data(TestData) + + def test_apply_one_addition(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": '', + "data": { + 'type': 'A', + 'ttl': 60, + 'value': '1.2.3.4' + } + } + ] + OwnRecords = { + "totalEntries": 0, + "records": [] + } + ExpectChanges = True + ExpectedAdditions = { + "records": [{ + "name": "unit.tests.", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }] + } + ExpectedDeletions = None + ExpectedUpdates = None + return self._test_apply_with_data(TestData) + + def test_apply_multiple_additions_exploding(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": '', + "data": { + 'type': 'A', + 'ttl': 60, + 'values': ['1.2.3.4', '1.2.3.5', '1.2.3.6'] + } + } + ] + OwnRecords = { + "totalEntries": 0, + "records": [] + } + ExpectChanges = True + ExpectedAdditions = { + "records": [{ + "name": "unit.tests.", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }, { + "name": "unit.tests.", + "type": "A", + "data": "1.2.3.5", + "ttl": 60 + }, { + "name": "unit.tests.", + "type": "A", + "data": "1.2.3.6", + "ttl": 60 + }] + } + ExpectedDeletions = None + ExpectedUpdates = None + return self._test_apply_with_data(TestData) + + def test_apply_multiple_additions_namespaced(self): + class TestData(object): + OtherRecords = [{ + "subdomain": 'foo', + "data": { + 'type': 'A', + 'ttl': 60, + 'value': '1.2.3.4' + } + }, { + "subdomain": 'bar', + "data": { + 'type': 'A', + 'ttl': 60, + 'value': '1.2.3.4' + } + }] + OwnRecords = { + "totalEntries": 0, + "records": [] + } + ExpectChanges = True + ExpectedAdditions = { + "records": [{ + "name": "bar.unit.tests.", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }, { + "name": "foo.unit.tests.", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }] + } + ExpectedDeletions = None + ExpectedUpdates = None + return self._test_apply_with_data(TestData) + + def test_apply_single_deletion(self): + class TestData(object): + OtherRecords = [] + OwnRecords = { + "totalEntries": 1, + "records": [{ + "name": "unit.tests.", + "id": "A-111111", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }] + } + ExpectChanges = True + ExpectedAdditions = None + ExpectedDeletions = "id=A-111111" + ExpectedUpdates = None + return self._test_apply_with_data(TestData) + + def test_apply_multiple_deletions(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": '', + "data": { + 'type': 'A', + 'ttl': 60, + 'value': '1.2.3.5' + } + } + ] + OwnRecords = { + "totalEntries": 3, + "records": [{ + "name": "unit.tests.", + "id": "A-111111", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }, { + "name": "unit.tests.", + "id": "A-222222", + "type": "A", + "data": "1.2.3.5", + "ttl": 60 + }, { + "name": "unit.tests.", + "id": "A-333333", + "type": "A", + "data": "1.2.3.6", + "ttl": 60 + }] + } + ExpectChanges = True + ExpectedAdditions = None + ExpectedDeletions = "id=A-111111&id=A-333333" + ExpectedUpdates = { + "records": [{ + "name": "unit.tests.", + "id": "A-222222", + "data": "1.2.3.5", + "ttl": 60 + }] + } + return self._test_apply_with_data(TestData) + + def test_apply_multiple_deletions_cross_zone(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": '', + "data": { + 'type': 'A', + 'ttl': 60, + 'value': '1.2.3.4' + } + } + ] + OwnRecords = { + "totalEntries": 3, + "records": [{ + "name": "unit.tests.", + "id": "A-111111", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }, { + "name": "foo.unit.tests.", + "id": "A-222222", + "type": "A", + "data": "1.2.3.5", + "ttl": 60 + }, { + "name": "bar.unit.tests.", + "id": "A-333333", + "type": "A", + "data": "1.2.3.6", + "ttl": 60 + }] + } + ExpectChanges = True + ExpectedAdditions = None + ExpectedDeletions = "id=A-222222&id=A-333333" + ExpectedUpdates = None + return self._test_apply_with_data(TestData) + + def test_apply_single_update(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": '', + "data": { + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.4' + } + } + ] + OwnRecords = { + "totalEntries": 1, + "records": [{ + "name": "unit.tests.", + "id": "A-111111", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }] + } + ExpectChanges = True + ExpectedAdditions = None + ExpectedDeletions = None + ExpectedUpdates = { + "records": [{ + "name": "unit.tests.", + "id": "A-111111", + "data": "1.2.3.4", + "ttl": 3600 + }] + } + return self._test_apply_with_data(TestData) + + def test_apply_multiple_updates(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": '', + "data": { + 'type': 'A', + 'ttl': 3600, + 'values': ['1.2.3.4', '1.2.3.5', '1.2.3.6'] + } + } + ] + OwnRecords = { + "totalEntries": 3, + "records": [{ + "name": "unit.tests.", + "id": "A-111111", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }, { + "name": "unit.tests.", + "id": "A-222222", + "type": "A", + "data": "1.2.3.5", + "ttl": 60 + }, { + "name": "unit.tests.", + "id": "A-333333", + "type": "A", + "data": "1.2.3.6", + "ttl": 60 + }] + } + ExpectChanges = True + ExpectedAdditions = None + ExpectedDeletions = None + ExpectedUpdates = { + "records": [{ + "name": "unit.tests.", + "id": "A-111111", + "data": "1.2.3.4", + "ttl": 3600 + }, { + "name": "unit.tests.", + "id": "A-222222", + "data": "1.2.3.5", + "ttl": 3600 + }, { + "name": "unit.tests.", + "id": "A-333333", + "data": "1.2.3.6", + "ttl": 3600 + }] + } + return self._test_apply_with_data(TestData) + + def test_apply_multiple_updates_cross_zone(self): + class TestData(object): + OtherRecords = [ + { + "subdomain": 'foo', + "data": { + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.4' + } + }, + { + "subdomain": 'bar', + "data": { + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.4' + } + } + ] + OwnRecords = { + "totalEntries": 2, + "records": [{ + "name": "foo.unit.tests.", + "id": "A-111111", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }, { + "name": "bar.unit.tests.", + "id": "A-222222", + "type": "A", + "data": "1.2.3.4", + "ttl": 60 + }] + } + ExpectChanges = True + ExpectedAdditions = None + ExpectedDeletions = None + ExpectedUpdates = { + "records": [{ + "name": "bar.unit.tests.", + "id": "A-222222", + "data": "1.2.3.4", + "ttl": 3600 + }, { + "name": "foo.unit.tests.", + "id": "A-111111", + "data": "1.2.3.4", + "ttl": 3600 + }] + } + return self._test_apply_with_data(TestData) + + def test_provider(self): + expected = self._load_full_config() + + # No existing records -> creates for every record in expected + with requests_mock() as mock: + mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) + mock.get(re.compile('records'), status_code=200, text=EMPTY_TEXT) + + plan = self.provider.plan(expected) + self.assertTrue(mock.called) + self.assertEquals(len(expected.records), len(plan.changes)) + # Used in a minute def assert_rrsets_callback(request, context): data = loads(request.body) self.assertEquals(expected_n, len(data['rrsets'])) return '' - # No existing records -> creates for every record in expected with requests_mock() as mock: - mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) - mock.get(re.compile('records'), status_code=200, text=EMPTY_TEXT) - # post 201, is reponse to the create with data + # post 201, is response to the create with data mock.patch(ANY, status_code=201, text=assert_rrsets_callback) - plan = provider.plan(expected) - self.assertEquals(expected_n, len(plan.changes)) - self.assertEquals(expected_n, provider.apply(plan)) + self.assertEquals(expected_n, self.provider.apply(plan)) # Non-existent zone -> creates for every record in expected # OMG this is fucking ugly, probably better to ditch requests_mocks and @@ -123,12 +665,12 @@ class TestRackspaceSource(TestCase): mock.get(ANY, status_code=422, text='') # patch 422's, unknown zone mock.patch(ANY, status_code=422, text=dumps(not_found)) - # post 201, is reponse to the create with data + # post 201, is response to the create with data mock.post(ANY, status_code=201, text=assert_rrsets_callback) - plan = provider.plan(expected) + plan = self.provider.plan(expected) self.assertEquals(expected_n, len(plan.changes)) - self.assertEquals(expected_n, provider.apply(plan)) + self.assertEquals(expected_n, self.provider.apply(plan)) with requests_mock() as mock: # get 422's, unknown zone @@ -138,8 +680,8 @@ class TestRackspaceSource(TestCase): mock.patch(ANY, status_code=422, text=dumps(data)) with self.assertRaises(HTTPError) as ctx: - plan = provider.plan(expected) - provider.apply(plan) + plan = self.provider.plan(expected) + self.provider.apply(plan) response = ctx.exception.response self.assertEquals(422, response.status_code) self.assertTrue('error' in response.json()) @@ -151,8 +693,8 @@ class TestRackspaceSource(TestCase): mock.patch(ANY, status_code=500, text='') with self.assertRaises(HTTPError): - plan = provider.plan(expected) - provider.apply(plan) + plan = self.provider.plan(expected) + self.provider.apply(plan) with requests_mock() as mock: # get 422's, unknown zone @@ -163,133 +705,94 @@ class TestRackspaceSource(TestCase): mock.post(ANY, status_code=422, text='Hello Word!') with self.assertRaises(HTTPError): - plan = provider.plan(expected) - provider.apply(plan) - - def test_small_change(self): - provider = load_provider() + plan = self.provider.plan(expected) + self.provider.apply(plan) + def test_plan_no_changes(self): expected = Zone('unit.tests.', []) - source = YamlProvider('test', join(dirname(__file__), 'config')) - source.populate(expected) - self.assertEquals(15, len(expected.records)) + expected.add_record(Record.new(expected, '', { + 'type': 'NS', + 'ttl': 600, + 'values': ['8.8.8.8.', '9.9.9.9.'] + })) + expected.add_record(Record.new(expected, '', { + 'type': 'A', + 'ttl': 60, + 'value': '1.2.3.4' + })) - # A small change to a single record with requests_mock() as mock: - mock.get(ANY, status_code=200, text=FULL_TEXT) - - missing = Zone(expected.name, []) - # Find and delete the SPF record - for record in expected.records: - if record._type != 'SPF': - missing.add_record(record) - - def assert_delete_callback(request, context): - self.assertEquals({ - 'rrsets': [{ - 'records': [ - {'content': '"v=spf1 ip4:192.168.0.1/16-all"', - 'disabled': False} - ], - 'changetype': 'DELETE', - 'type': 'SPF', - 'name': 'spf.unit.tests.', - 'ttl': 600 - }] - }, loads(request.body)) - return '' - - mock.patch(ANY, status_code=201, text=assert_delete_callback) - - plan = provider.plan(missing) - self.assertEquals(1, len(plan.changes)) - self.assertEquals(1, provider.apply(plan)) + mock.get(re.compile('domains/.*/records'), status_code=200, text=RECORDS_EXISTING_NAMESERVERS) + mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) - def test_existing_nameservers(self): - ns_values = ['8.8.8.8.', '9.9.9.9.'] - provider = load_provider() + plan = self.provider.plan(expected) + self.assertTrue(mock.called) + self.assertFalse(plan) + + def test_plan_remove_a_record(self): expected = Zone('unit.tests.', []) - ns_record = Record.new(expected, '', { + expected.add_record(Record.new(expected, '', { 'type': 'NS', 'ttl': 600, - 'values': ns_values - }) - expected.add_record(ns_record) + 'values': ['8.8.8.8.', '9.9.9.9.'] + })) - # no changes with requests_mock() as mock: - data = { - 'rrsets': [{ - 'comments': [], - 'name': 'unit.tests.', - 'records': [ - { - 'content': '8.8.8.8.', - 'disabled': False - }, - { - 'content': '9.9.9.9.', - 'disabled': False - } - ], - 'ttl': 600, - 'type': 'NS' - }, { - 'comments': [], - 'name': 'unit.tests.', - 'records': [{ - 'content': '1.2.3.4', - 'disabled': False, - }], - 'ttl': 60, - 'type': 'A' - }] - } - mock.get(ANY, status_code=200, json=data) - - unrelated_record = Record.new(expected, '', { - 'type': 'A', - 'ttl': 60, - 'value': '1.2.3.4' - }) - expected.add_record(unrelated_record) - plan = provider.plan(expected) - self.assertFalse(plan) - # remove it now that we don't need the unrelated change any longer - expected.records.remove(unrelated_record) + mock.get(re.compile('domains/.*/records'), status_code=200, text=RECORDS_EXISTING_NAMESERVERS) + mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) + + plan = self.provider.plan(expected) + self.assertTrue(mock.called) + self.assertEquals(1, len(plan.changes)) + self.assertEqual(plan.changes[0].existing.ttl, 60) + self.assertEqual(plan.changes[0].existing.values[0], '1.2.3.4') + + def test_plan_create_a_record(self): + expected = Zone('unit.tests.', []) + expected.add_record(Record.new(expected, '', { + 'type': 'NS', + 'ttl': 600, + 'values': ['8.8.8.8.', '9.9.9.9.'] + })) + expected.add_record(Record.new(expected, '', { + 'type': 'A', + 'ttl': 60, + 'values': ['1.2.3.4', '1.2.3.5'] + })) - # ttl diff with requests_mock() as mock: - data = { - 'rrsets': [{ - 'comments': [], - 'name': 'unit.tests.', - 'records': [ - { - 'content': '8.8.8.8.', - 'disabled': False - }, - { - 'content': '9.9.9.9.', - 'disabled': False - }, - ], - 'ttl': 3600, - 'type': 'NS' - }] - } - mock.get(ANY, status_code=200, json=data) + mock.get(re.compile('domains/.*/records'), status_code=200, text=RECORDS_EXISTING_NAMESERVERS) + mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) - plan = provider.plan(expected) + plan = self.provider.plan(expected) + self.assertTrue(mock.called) self.assertEquals(1, len(plan.changes)) + self.assertEqual(plan.changes[0].new.ttl, 60) + self.assertEqual(plan.changes[0].new.values[0], '1.2.3.4') + self.assertEqual(plan.changes[0].new.values[1], '1.2.3.5') + + def test_plan_change_ttl(self): + expected = Zone('unit.tests.', []) + expected.add_record(Record.new(expected, '', { + 'type': 'NS', + 'ttl': 600, + 'values': ['8.8.8.8.', '9.9.9.9.'] + })) + expected.add_record(Record.new(expected, '', { + 'type': 'A', + 'ttl': 86400, + 'value': '1.2.3.4' + })) - # create with requests_mock() as mock: - data = { - 'rrsets': [] - } - mock.get(ANY, status_code=200, json=data) + mock.get(re.compile('domains/.*/records'), status_code=200, text=RECORDS_EXISTING_NAMESERVERS) + mock.get(re.compile('domains$'), status_code=200, text=LIST_DOMAINS_RESPONSE) - plan = provider.plan(expected) - self.assertEquals(1, len(plan.changes)) + plan = self.provider.plan(expected) + + self.assertTrue(mock.called) + self.assertEqual(1, len(plan.changes)) + self.assertEqual(plan.changes[0].existing.ttl, 60) + self.assertEqual(plan.changes[0].new.ttl, 86400) + self.assertEqual(plan.changes[0].new.values[0], '1.2.3.4')