diff --git a/octodns/provider/rackspace.py b/octodns/provider/rackspace.py new file mode 100644 index 0000000..311d02d --- /dev/null +++ b/octodns/provider/rackspace.py @@ -0,0 +1,386 @@ +# +# +# + + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from requests import HTTPError, Session, post +import json +import logging + +from ..record import Create, Record +from .base import BaseProvider + + +class RackspaceProvider(BaseProvider): + SUPPORTS_GEO = False + TIMEOUT = 5 + + def __init__(self, username, api_key, *args, **kwargs): + ''' + Rackspace API v1 Provider + + rackspace: + class: octodns.provider.rackspace.RackspaceProvider + # The the username to authenticate with (required) + username: username + # The api key that grants access for that user (required) + api_key: api-key + ''' + self.log = logging.getLogger('RackspaceProvider[{}]'.format(username)) + super(RackspaceProvider, self).__init__(id, *args, **kwargs) + + auth_token, dns_endpoint = self._get_auth_token(username, api_key) + self.dns_endpoint = dns_endpoint + + sess = Session() + sess.headers.update({'X-Auth-Token': auth_token}) + self._sess = sess + + 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}}}, + ) + cloud_dns_endpoint = [x for x in ret.json()['access']['serviceCatalog'] if x['name'] == 'cloudDNS'][0]['endpoints'][0]['publicURL'] + return ret.json()['access']['token']['id'], cloud_dns_endpoint + + def _get_zone_id_for(self, zone_name): + ret = self._request('GET', 'domains', pagination_key='domains') + if ret and 'name' in ret: + return [x for x in ret if x['name'] == zone_name][0]['id'] + else: + return None + + def _request(self, method, path, data=None, pagination_key=None): + self.log.debug('_request: method=%s, path=%s', method, path) + url = '{}/{}'.format(self.dns_endpoint, path) + + if pagination_key: + return self._paginated_request_for_url(method, url, data, pagination_key) + else: + return self._request_for_url(method, url, data) + + def _request_for_url(self, method, url, data): + resp = self._sess.request(method, url, json=data, timeout=self.TIMEOUT) + self.log.debug('_request: status=%d', resp.status_code) + resp.raise_for_status() + return resp + + def _paginated_request_for_url(self, method, url, data, pagination_key): + acc = [] + + resp = self._sess.request(method, url, json=data, timeout=self.TIMEOUT) + self.log.debug('_request: status=%d', resp.status_code) + resp.raise_for_status() + acc.extend(resp.json()[pagination_key]) + + next_page = [x for x in resp.json().get('links', []) if x['rel'] == 'next'] + if next_page: + url = next_page[0]['href'] + return acc.extend(self._paginated_request_for_url(method, url, data, pagination_key)) + else: + return acc + + def _get(self, path, data=None): + return self._request('GET', path, data=data) + + def _post(self, path, data=None): + return self._request('POST', path, data=data) + + def _patch(self, path, data=None): + return self._request('PATCH', path, data=data) + + def _data_for_multiple(self, rrset): + # TODO: geo not supported + return { + 'type': rrset['type'], + 'values': [r['content'] for r in rrset['records']], + 'ttl': rrset['ttl'] + } + + _data_for_A = _data_for_multiple + _data_for_AAAA = _data_for_multiple + _data_for_NS = _data_for_multiple + + def _data_for_single(self, record): + return { + 'type': record['type'], + 'value': record['data'], + 'ttl': record['ttl'] + } + + _data_for_ALIAS = _data_for_single + _data_for_CNAME = _data_for_single + _data_for_PTR = _data_for_single + + def _data_for_quoted(self, rrset): + return { + 'type': rrset['type'], + 'values': [r['content'][1:-1] for r in rrset['records']], + 'ttl': rrset['ttl'] + } + + _data_for_SPF = _data_for_quoted + _data_for_TXT = _data_for_quoted + + def _data_for_MX(self, rrset): + values = [] + for record in rrset['records']: + priority, value = record['content'].split(' ', 1) + values.append({ + 'priority': priority, + 'value': value, + }) + return { + 'type': rrset['type'], + 'values': values, + 'ttl': rrset['ttl'] + } + + def _data_for_NAPTR(self, rrset): + values = [] + for record in rrset['records']: + order, preference, flags, service, regexp, replacement = \ + record['content'].split(' ', 5) + values.append({ + 'order': order, + 'preference': preference, + 'flags': flags[1:-1], + 'service': service[1:-1], + 'regexp': regexp[1:-1], + 'replacement': replacement, + }) + return { + 'type': rrset['type'], + 'values': values, + 'ttl': rrset['ttl'] + } + + def _data_for_SSHFP(self, rrset): + values = [] + for record in rrset['records']: + algorithm, fingerprint_type, fingerprint = \ + record['content'].split(' ', 2) + values.append({ + 'algorithm': algorithm, + 'fingerprint_type': fingerprint_type, + 'fingerprint': fingerprint, + }) + return { + 'type': rrset['type'], + 'values': values, + 'ttl': rrset['ttl'] + } + + def _data_for_SRV(self, rrset): + values = [] + for record in rrset['records']: + priority, weight, port, target = \ + record['content'].split(' ', 3) + values.append({ + 'priority': priority, + 'weight': weight, + 'port': port, + 'target': target, + }) + return { + 'type': rrset['type'], + 'values': values, + 'ttl': rrset['ttl'] + } + + def populate(self, zone, target=False): + self.log.debug('populate: name=%s', zone.name) + resp = None + try: + domain_id = self._get_zone_id_for(zone.name) + resp = 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: + # Nicer error message for auth problems + raise Exception('Rackspace request unauthorized') + elif e.response.status_code == 422: + # 422 means powerdns doesn't know anything about the requsted + # domain. We'll just ignore it here and leave the zone + # untouched. + pass + else: + # just re-throw + raise + + before = len(zone.records) + + if resp: + for record in resp.json()['records']: + record_type = record['type'] + if record_type == 'SOA': + continue + data_for = getattr(self, '_data_for_{}'.format(record_type)) + record_name = zone.hostname_from_fqdn(record['name']) + record = Record.new(zone, record_name, data_for(record), + source=self) + zone.add_record(record) + + self.log.info('populate: found %s records', + len(zone.records) - before) + + def _records_for_multiple(self, record): + return [{'content': v, 'disabled': False} + for v in record.values] + + _records_for_A = _records_for_multiple + _records_for_AAAA = _records_for_multiple + _records_for_NS = _records_for_multiple + + def _records_for_single(self, record): + return [{'content': record.value, 'disabled': False}] + + _records_for_ALIAS = _records_for_single + _records_for_CNAME = _records_for_single + _records_for_PTR = _records_for_single + + def _records_for_quoted(self, record): + return [{'content': '"{}"'.format(v), 'disabled': False} + for v in record.values] + + _records_for_SPF = _records_for_quoted + _records_for_TXT = _records_for_quoted + + def _records_for_MX(self, record): + return [{ + 'content': '{} {}'.format(v.priority, v.value), + 'disabled': False + } for v in record.values] + + def _records_for_NAPTR(self, record): + return [{ + 'content': '{} {} "{}" "{}" "{}" {}'.format(v.order, v.preference, + v.flags, v.service, + v.regexp, + v.replacement), + 'disabled': False + } for v in record.values] + + def _records_for_SSHFP(self, record): + return [{ + 'content': '{} {} {}'.format(v.algorithm, v.fingerprint_type, + v.fingerprint), + 'disabled': False + } for v in record.values] + + def _records_for_SRV(self, record): + return [{ + 'content': '{} {} {} {}'.format(v.priority, v.weight, v.port, + v.target), + 'disabled': False + } 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 + + 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) + } + + 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 _apply(self, plan): + desired = plan.desired + changes = plan.changes + self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, + len(changes)) + + mods = [] + 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') + + diff --git a/tests/fixtures/rackspace-auth-response.json b/tests/fixtures/rackspace-auth-response.json new file mode 100644 index 0000000..cc811c7 --- /dev/null +++ b/tests/fixtures/rackspace-auth-response.json @@ -0,0 +1,87 @@ +{ + "access": { + "token": { + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "expires": "2014-11-24T22:05:39.115Z", + "tenant": { + "id": "110011", + "name": "110011" + }, + "RAX-AUTH:authenticatedBy": [ + "APIKEY" + ] + }, + "serviceCatalog": [ + { + "name": "cloudDatabases", + "endpoints": [ + { + "publicURL": "https://syd.databases.api.rackspacecloud.com/v1.0/110011", + "region": "SYD", + "tenantId": "110011" + }, + { + "publicURL": "https://dfw.databases.api.rackspacecloud.com/v1.0/110011", + "region": "DFW", + "tenantId": "110011" + }, + { + "publicURL": "https://ord.databases.api.rackspacecloud.com/v1.0/110011", + "region": "ORD", + "tenantId": "110011" + }, + { + "publicURL": "https://iad.databases.api.rackspacecloud.com/v1.0/110011", + "region": "IAD", + "tenantId": "110011" + }, + { + "publicURL": "https://hkg.databases.api.rackspacecloud.com/v1.0/110011", + "region": "HKG", + "tenantId": "110011" + } + ], + "type": "rax:database" + }, + { + "name": "cloudDNS", + "endpoints": [ + { + "publicURL": "https://dns.api.rackspacecloud.com/v1.0/110011", + "tenantId": "110011" + } + ], + "type": "rax:dns" + }, + { + "name": "rackCDN", + "endpoints": [ + { + "internalURL": "https://global.cdn.api.rackspacecloud.com/v1.0/110011", + "publicURL": "https://global.cdn.api.rackspacecloud.com/v1.0/110011", + "tenantId": "110011" + } + ], + "type": "rax:cdn" + } + ], + "user": { + "id": "123456", + "roles": [ + { + "description": "A Role that allows a user access to keystone Service methods", + "id": "6", + "name": "compute:default", + "tenantId": "110011" + }, + { + "description": "User Admin Role.", + "id": "3", + "name": "identity:user-admin" + } + ], + "name": "jsmith", + "RAX-AUTH:defaultRegion": "ORD" + } + } +} \ No newline at end of file diff --git a/tests/fixtures/rackspace-list-domains-response.json b/tests/fixtures/rackspace-list-domains-response.json new file mode 100644 index 0000000..f124837 --- /dev/null +++ b/tests/fixtures/rackspace-list-domains-response.json @@ -0,0 +1,68 @@ +{ + "totalEntries" : 10, + "domains" : [ { + "name" : "example.com", + "id" : 2725233, + "comment" : "Optional domain comment...", + "updated" : "2011-06-24T01:23:15.000+0000", + "accountId" : 1234, + "emailAddress" : "sample@rackspace.com", + "created" : "2011-06-24T01:12:51.000+0000" + }, { + "name" : "sub1.example.com", + "id" : 2725257, + "comment" : "1st sample subdomain", + "updated" : "2011-06-23T03:09:34.000+0000", + "accountId" : 1234, + "emailAddress" : "sample@rackspace.com", + "created" : "2011-06-23T03:09:33.000+0000" + }, { + "name" : "sub2.example.com", + "id" : 2725258, + "comment" : "1st sample subdomain", + "updated" : "2011-06-23T03:52:55.000+0000", + "accountId" : 1234, + "emailAddress" : "sample@rackspace.com", + "created" : "2011-06-23T03:52:55.000+0000" + }, { + "name" : "north.example.com", + "id" : 2725260, + "updated" : "2011-06-23T03:53:10.000+0000", + "accountId" : 1234, + "emailAddress" : "sample@rackspace.com", + "created" : "2011-06-23T03:53:09.000+0000" + }, { + "name" : "south.example.com", + "id" : 2725261, + "comment" : "Final sample subdomain", + "updated" : "2011-06-23T03:53:14.000+0000", + "accountId" : 1234, + "emailAddress" : "sample@rackspace.com", + "created" : "2011-06-23T03:53:14.000+0000" + }, { + "name" : "region2.example.net", + "id" : 2725352, + "updated" : "2011-06-23T20:21:06.000+0000", + "accountId" : 1234, + "created" : "2011-06-23T19:24:27.000+0000" + }, { + "name" : "example.org", + "id" : 2718984, + "updated" : "2011-05-03T14:47:32.000+0000", + "accountId" : 1234, + "created" : "2011-05-03T14:47:30.000+0000" + }, { + "name" : "rackspace.example", + "id" : 2722346, + "updated" : "2011-06-21T15:54:31.000+0000", + "accountId" : 1234, + "created" : "2011-06-15T19:02:07.000+0000" + }, { + "name" : "dnsaas.example", + "id" : 2722347, + "comment" : "Sample comment", + "updated" : "2011-06-21T15:54:31.000+0000", + "accountId" : 1234, + "created" : "2011-06-15T19:02:07.000+0000" + } ] +} diff --git a/tests/fixtures/rackspace-sample-recordset-page1.json b/tests/fixtures/rackspace-sample-recordset-page1.json new file mode 100644 index 0000000..72dc7dd --- /dev/null +++ b/tests/fixtures/rackspace-sample-recordset-page1.json @@ -0,0 +1,33 @@ +{ + "totalEntries" : 6, + "records" : [ { + "name" : "ftp.example.com", + "id" : "A-6817754", + "type" : "A", + "data" : "192.0.2.8", + "updated" : "2011-05-19T13:07:08.000+0000", + "ttl" : 5771, + "created" : "2011-05-18T19:53:09.000+0000" + }, { + "name" : "example.com", + "id" : "A-6822994", + "type" : "A", + "data" : "192.0.2.17", + "updated" : "2011-06-24T01:12:52.000+0000", + "ttl" : 86400, + "created" : "2011-06-24T01:12:52.000+0000" + }, { + "name" : "example.com", + "id" : "NS-6251982", + "type" : "NS", + "data" : "ns.rackspace.com", + "updated" : "2011-06-24T01:12:51.000+0000", + "ttl" : 3600, + "created" : "2011-06-24T01:12:51.000+0000" + } ], + "links" : [ { + "content" : "", + "href" : "https://localhost/v1.0/1234/domains/domain_id/records?limit=3&offset=3", + "rel" : "next" + } ] +} diff --git a/tests/fixtures/rackspace-sample-recordset-page2.json b/tests/fixtures/rackspace-sample-recordset-page2.json new file mode 100644 index 0000000..dc3e39a --- /dev/null +++ b/tests/fixtures/rackspace-sample-recordset-page2.json @@ -0,0 +1,35 @@ +{ + "totalEntries" : 6, + "records" : [ { + "name" : "example.com", + "id" : "NS-6251983", + "type" : "NS", + "data" : "ns2.rackspace.com", + "updated" : "2011-06-24T01:12:51.000+0000", + "ttl" : 3600, + "created" : "2011-06-24T01:12:51.000+0000" + }, { + "name" : "example.com", + "priority" : 5, + "id" : "MX-3151218", + "type" : "MX", + "data" : "mail.example.com", + "updated" : "2011-06-24T01:12:53.000+0000", + "ttl" : 3600, + "created" : "2011-06-24T01:12:53.000+0000" + }, { + "name" : "www.example.com", + "id" : "CNAME-9778009", + "type" : "CNAME", + "comment" : "This is a comment on the CNAME record", + "data" : "example.com", + "updated" : "2011-06-24T01:12:54.000+0000", + "ttl" : 5400, + "created" : "2011-06-24T01:12:54.000+0000" + } ], + "links" : [ { + "content" : "", + "href" : "https://dns.api.rackspacecloud.com/v1.0/1234/domains/domain_id/records?limit=3&offset=0", + "rel" : "previous" + }] +} diff --git a/tests/test_octodns_source_rackspace.py b/tests/test_octodns_source_rackspace.py new file mode 100644 index 0000000..88ccb5f --- /dev/null +++ b/tests/test_octodns_source_rackspace.py @@ -0,0 +1,294 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import re +from json import loads, dumps +from os.path import dirname, join +from unittest import TestCase + +from requests import HTTPError +from requests_mock import ANY, mock as requests_mock + +from octodns.provider.rackspace import RackspaceProvider +from octodns.provider.yaml import YamlProvider +from octodns.record import Record +from octodns.zone import Zone + +EMPTY_TEXT = ''' +{ + "totalEntries" : 6, + "records" : [] +} +''' + +with open('./tests/fixtures/rackspace-auth-response.json') as fh: + AUTH_RESPONSE = fh.read() + +with open('./tests/fixtures/rackspace-list-domains-response.json') as fh: + LIST_DOMAINS_RESPONSE = fh.read() + +with open('./tests/fixtures/rackspace-sample-recordset-page1.json') as fh: + RECORDS_PAGE_1 = fh.read() + +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): + + def test_provider(self): + provider = load_provider() + + # Bad auth + 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.assertTrue('unauthorized' in ctx.exception.message) + + # 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-existant 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.assertEquals(set(), zone.records) + + # The rest of this is messy/complicated b/c it's dealing with mocking + + 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) + + # No diffs == no changes + 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.', []) + provider.populate(zone) + self.assertEquals(14, len(zone.records)) + changes = expected.changes(zone, provider) + self.assertEquals(0, len(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(ANY, status_code=200, text=EMPTY_TEXT) + # post 201, is reponse 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)) + + # Non-existent zone -> creates for every record in expected + # OMG this is fucking ugly, probably better to ditch requests_mocks and + # just mock things for real as it doesn't seem to provide a way to get + # at the request params or verify that things were called from what I + # can tell + not_found = {'error': "Could not find domain 'unit.tests.'"} + with requests_mock() as mock: + # get 422's, unknown zone + 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 + mock.post(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)) + + with requests_mock() as mock: + # get 422's, unknown zone + mock.get(ANY, status_code=422, text='') + # patch 422's, + data = {'error': "Key 'name' not present or not a String"} + mock.patch(ANY, status_code=422, text=dumps(data)) + + with self.assertRaises(HTTPError) as ctx: + plan = provider.plan(expected) + provider.apply(plan) + response = ctx.exception.response + self.assertEquals(422, response.status_code) + self.assertTrue('error' in response.json()) + + with requests_mock() as mock: + # get 422's, unknown zone + mock.get(ANY, status_code=422, text='') + # patch 500's, things just blew up + mock.patch(ANY, status_code=500, text='') + + with self.assertRaises(HTTPError): + plan = provider.plan(expected) + provider.apply(plan) + + with requests_mock() as mock: + # get 422's, unknown zone + mock.get(ANY, status_code=422, text='') + # patch 500's, things just blew up + mock.patch(ANY, status_code=422, text=dumps(not_found)) + # post 422's, something wrong with create + 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() + + expected = Zone('unit.tests.', []) + source = YamlProvider('test', join(dirname(__file__), 'config')) + source.populate(expected) + self.assertEquals(15, len(expected.records)) + + # 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)) + + def test_existing_nameservers(self): + ns_values = ['8.8.8.8.', '9.9.9.9.'] + provider = load_provider() + + expected = Zone('unit.tests.', []) + ns_record = Record.new(expected, '', { + 'type': 'NS', + 'ttl': 600, + 'values': ns_values + }) + expected.add_record(ns_record) + + # 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) + + # 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) + + plan = provider.plan(expected) + self.assertEquals(1, len(plan.changes)) + + # create + with requests_mock() as mock: + data = { + 'rrsets': [] + } + mock.get(ANY, status_code=200, json=data) + + plan = provider.plan(expected) + self.assertEquals(1, len(plan.changes))