Browse Source

Merge pull request #178 from yzguy/dnsmadeeasy-provider

Dnsmadeeasy provider
pull/185/head
Ross McFarland 8 years ago
committed by GitHub
parent
commit
507e748b98
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 913 additions and 0 deletions
  1. +1
    -0
      README.md
  2. +382
    -0
      octodns/provider/dnsmadeeasy.py
  3. +16
    -0
      tests/fixtures/dnsmadeeasy-domains.json
  4. +312
    -0
      tests/fixtures/dnsmadeeasy-records.json
  5. +202
    -0
      tests/test_octodns_provider_dnsmadeeasy.py

+ 1
- 0
README.md View File

@ -152,6 +152,7 @@ The above command pulled the existing data out of Route53 and placed the results
| [AzureProvider](/octodns/provider/azuredns.py) | A, AAAA, CNAME, MX, NS, PTR, SRV, TXT | No | |
| [CloudflareProvider](/octodns/provider/cloudflare.py) | A, AAAA, ALIAS, CAA, CNAME, MX, NS, SPF, TXT | No | CAA tags restricted |
| [DigitalOceanProvider](/octodns/provider/digitalocean.py) | A, AAAA, CAA, CNAME, MX, NS, TXT, SRV | No | CAA tags restricted |
| [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
| [DnsimpleProvider](/octodns/provider/dnsimple.py) | All | No | CAA tags restricted |
| [DynProvider](/octodns/provider/dyn.py) | All | Yes | |
| [GoogleCloudProvider](/octodns/provider/googlecloud.py) | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | |


+ 382
- 0
octodns/provider/dnsmadeeasy.py View File

@ -0,0 +1,382 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from collections import defaultdict
from requests import Session
from time import strftime, gmtime, sleep
import hashlib
import hmac
import logging
from ..record import Record
from .base import BaseProvider
class DnsMadeEasyClientException(Exception):
pass
class DnsMadeEasyClientBadRequest(DnsMadeEasyClientException):
def __init__(self, resp):
errors = resp.json()['error']
super(DnsMadeEasyClientBadRequest, self).__init__(
'\n - {}'.format('\n - '.join(errors)))
class DnsMadeEasyClientUnauthorized(DnsMadeEasyClientException):
def __init__(self):
super(DnsMadeEasyClientUnauthorized, self).__init__('Unauthorized')
class DnsMadeEasyClientNotFound(DnsMadeEasyClientException):
def __init__(self):
super(DnsMadeEasyClientNotFound, self).__init__('Not Found')
class DnsMadeEasyClient(object):
PRODUCTION = 'https://api.dnsmadeeasy.com/V2.0/dns/managed'
SANDBOX = 'https://api.sandbox.dnsmadeeasy.com/V2.0/dns/managed'
def __init__(self, api_key, secret_key, sandbox=False,
ratelimit_delay=0.0):
self.api_key = api_key
self.secret_key = secret_key
self._base = self.SANDBOX if sandbox else self.PRODUCTION
self.ratelimit_delay = ratelimit_delay
self._sess = Session()
self._sess.headers.update({'x-dnsme-apiKey': self.api_key})
self._domains = None
def _current_time(self):
return strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
def _hmac_hash(self, now):
return hmac.new(self.secret_key.encode(), now.encode(),
hashlib.sha1).hexdigest()
def _request(self, method, path, params=None, data=None):
now = self._current_time()
hmac_hash = self._hmac_hash(now)
headers = {
'x-dnsme-hmac': hmac_hash,
'x-dnsme-requestDate': now
}
url = '{}{}'.format(self._base, path)
resp = self._sess.request(method, url, headers=headers,
params=params, json=data)
if resp.status_code == 400:
raise DnsMadeEasyClientBadRequest(resp)
if resp.status_code in [401, 403]:
raise DnsMadeEasyClientUnauthorized()
if resp.status_code == 404:
raise DnsMadeEasyClientNotFound()
resp.raise_for_status()
sleep(self.ratelimit_delay)
return resp
@property
def domains(self):
if self._domains is None:
zones = []
# has pages in resp, do we need paging?
resp = self._request('GET', '/').json()
zones += resp['data']
self._domains = {'{}.'.format(z['name']): z['id'] for z in zones}
return self._domains
def domain(self, name):
path = '/id/{}'.format(name)
return self._request('GET', path).json()
def domain_create(self, name):
self._request('POST', '/', data={'name': name})
def records(self, zone_name):
zone_id = self.domains.get(zone_name, False)
path = '/{}/records'.format(zone_id)
ret = []
# has pages in resp, do we need paging?
resp = self._request('GET', path).json()
ret += resp['data']
# change relative values to absolute
for record in ret:
value = record['value']
if record['type'] in ['CNAME', 'MX', 'NS', 'SRV']:
if value == '':
record['value'] = zone_name
elif not value.endswith('.'):
record['value'] = '{}.{}'.format(value, zone_name)
return ret
def record_create(self, zone_name, params):
zone_id = self.domains.get(zone_name, False)
path = '/{}/records'.format(zone_id)
self._request('POST', path, data=params)
def record_delete(self, zone_name, record_id):
zone_id = self.domains.get(zone_name, False)
path = '/{}/records/{}'.format(zone_id, record_id)
self._request('DELETE', path)
class DnsMadeEasyProvider(BaseProvider):
'''
DNSMadeEasy DNS provider using v2.0 API
dnsmadeeasy:
class: octodns.provider.dnsmadeeasy.DnsMadeEasyProvider
# Your DnsMadeEasy api key (required)
api_key: env/DNSMADEEASY_API_KEY
# Your DnsMadeEasy secret key (required)
secret_key: env/DNSMADEEASY_SECRET_KEY
# Whether or not to use Sandbox environment
# (optional, default is false)
sandbox: true
'''
SUPPORTS_GEO = False
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX',
'NS', 'PTR', 'SPF', 'SRV', 'TXT'))
def __init__(self, id, api_key, secret_key, sandbox=False,
ratelimit_delay=0.0, *args, **kwargs):
self.log = logging.getLogger('DnsMadeEasyProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, api_key=***, secret_key=***, '
'sandbox=%s', id, sandbox)
super(DnsMadeEasyProvider, self).__init__(id, *args, **kwargs)
self._client = DnsMadeEasyClient(api_key, secret_key, sandbox,
ratelimit_delay)
self._zone_records = {}
def _data_for_multiple(self, _type, records):
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': [r['value'] for r in records]
}
_data_for_A = _data_for_multiple
_data_for_AAAA = _data_for_multiple
_data_for_NS = _data_for_multiple
def _data_for_CAA(self, _type, records):
values = []
for record in records:
values.append({
'flags': record['issuerCritical'],
'tag': record['caaType'],
'value': record['value'][1:-1]
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_TXT(self, _type, records):
values = [value['value'].replace(';', '\;') for value in records]
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
_data_for_SPF = _data_for_TXT
def _data_for_MX(self, _type, records):
values = []
for record in records:
values.append({
'preference': record['mxLevel'],
'exchange': record['value']
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_single(self, _type, records):
record = records[0]
return {
'ttl': record['ttl'],
'type': _type,
'value': record['value']
}
_data_for_CNAME = _data_for_single
_data_for_PTR = _data_for_single
def _data_for_SRV(self, _type, records):
values = []
for record in records:
values.append({
'port': record['port'],
'priority': record['priority'],
'target': record['value'],
'weight': record['weight']
})
return {
'type': _type,
'ttl': records[0]['ttl'],
'values': values
}
def zone_records(self, zone):
if zone.name not in self._zone_records:
try:
self._zone_records[zone.name] = \
self._client.records(zone.name)
except DnsMadeEasyClientNotFound:
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['type']
values[record['name']][record['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)
exists = zone.name in self._zone_records
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _params_for_multiple(self, record):
for value in record.values:
yield {
'value': value,
'name': record.name,
'ttl': record.ttl,
'type': record._type
}
_params_for_A = _params_for_multiple
_params_for_AAAA = _params_for_multiple
# An A record with this name must exist in this domain for
# this NS record to be valid. Need to handle checking if
# there is an A record before creating NS
_params_for_NS = _params_for_multiple
def _params_for_single(self, record):
yield {
'value': record.value,
'name': record.name,
'ttl': record.ttl,
'type': record._type
}
_params_for_CNAME = _params_for_single
_params_for_PTR = _params_for_single
def _params_for_MX(self, record):
for value in record.values:
yield {
'value': value.exchange,
'name': record.name,
'mxLevel': value.preference,
'ttl': record.ttl,
'type': record._type
}
def _params_for_SRV(self, record):
for value in record.values:
yield {
'value': value.target,
'name': record.name,
'port': value.port,
'priority': value.priority,
'ttl': record.ttl,
'type': record._type,
'weight': value.weight
}
def _params_for_TXT(self, record):
# DNSMadeEasy does not want values escaped
for value in record.chunked_values:
yield {
'value': value.replace('\;', ';'),
'name': record.name,
'ttl': record.ttl,
'type': record._type
}
_params_for_SPF = _params_for_TXT
def _params_for_CAA(self, record):
for value in record.values:
yield {
'value': value.value,
'issuerCritical': value.flags,
'name': record.name,
'caaType': value.tag,
'ttl': record.ttl,
'type': record._type
}
def _apply_Create(self, change):
new = change.new
params_for = getattr(self, '_params_for_{}'.format(new._type))
for params in params_for(new):
self._client.record_create(new.zone.name, params)
def _apply_Update(self, change):
self._apply_Delete(change)
self._apply_Create(change)
def _apply_Delete(self, change):
existing = change.existing
zone = existing.zone
for record in self.zone_records(zone):
if existing.name == record['name'] and \
existing._type == record['type']:
self._client.record_delete(zone.name, record['id'])
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
len(changes))
domain_name = desired.name[:-1]
try:
self._client.domain(domain_name)
except DnsMadeEasyClientNotFound:
self.log.debug('_apply: no matching zone, creating domain')
self._client.domain_create(domain_name)
for change in changes:
class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name))(change)
# Clear out the cache if any
self._zone_records.pop(desired.name, None)

+ 16
- 0
tests/fixtures/dnsmadeeasy-domains.json View File

@ -0,0 +1,16 @@
{
"totalPages": 1,
"totalRecords": 1,
"data": [{
"created": 1511740800000,
"folderId": 1990,
"gtdEnabled": false,
"pendingActionId": 0,
"updated": 1511766661574,
"processMulti": false,
"activeThirdParties": [],
"name": "unit.tests",
"id": 123123
}],
"page": 0
}

+ 312
- 0
tests/fixtures/dnsmadeeasy-records.json View File

@ -0,0 +1,312 @@
{
"totalPages": 1,
"totalRecords": 21,
"data": [{
"failover": false,
"monitor": false,
"sourceId": 123123,
"caaType": "issue",
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"issuerCritical": 0,
"ttl": 3600,
"source": 1,
"name": "",
"value": "\"ca.unit.tests\"",
"id": 11189874,
"type": "CAA"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 300,
"source": 1,
"name": "",
"value": "1.2.3.4",
"id": 11189875,
"type": "A"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 300,
"source": 1,
"name": "",
"value": "1.2.3.5",
"id": 11189876,
"type": "A"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 600,
"weight": 20,
"source": 1,
"name": "_srv._tcp",
"value": "foo-1.unit.tests.",
"id": 11189877,
"priority": 10,
"type": "SRV",
"port": 30
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 600,
"weight": 20,
"source": 1,
"name": "_srv._tcp",
"value": "foo-2.unit.tests.",
"id": 11189878,
"priority": 12,
"type": "SRV",
"port": 30
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 600,
"source": 1,
"name": "aaaa",
"value": "2601:644:500:e210:62f8:1dff:feb8:947a",
"id": 11189879,
"type": "AAAA"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 300,
"source": 1,
"name": "cname",
"value": "",
"id": 11189880,
"type": "CNAME"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 3600,
"source": 1,
"name": "included",
"value": "",
"id": 11189881,
"type": "CNAME"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"mxLevel": 30,
"ttl": 300,
"source": 1,
"name": "mx",
"value": "smtp-3.unit.tests.",
"id": 11189882,
"type": "MX"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"mxLevel": 20,
"ttl": 300,
"source": 1,
"name": "mx",
"value": "smtp-2.unit.tests.",
"id": 11189883,
"type": "MX"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"mxLevel": 10,
"ttl": 300,
"source": 1,
"name": "mx",
"value": "smtp-4.unit.tests.",
"id": 11189884,
"type": "MX"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"mxLevel": 40,
"ttl": 300,
"source": 1,
"name": "mx",
"value": "smtp-1.unit.tests.",
"id": 11189885,
"type": "MX"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 600,
"source": 1,
"name": "spf",
"value": "\"v=spf1 ip4:192.168.0.1/16-all\"",
"id": 11189886,
"type": "SPF"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 600,
"source": 1,
"name": "txt",
"value": "\"Bah bah black sheep\"",
"id": 11189887,
"type": "TXT"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 600,
"source": 1,
"name": "txt",
"value": "\"have you any wool.\"",
"id": 11189888,
"type": "TXT"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 600,
"source": 1,
"name": "txt",
"value": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"",
"id": 11189889,
"type": "TXT"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 3600,
"source": 1,
"name": "under",
"value": "ns1.unit.tests.",
"id": 11189890,
"type": "NS"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 3600,
"source": 1,
"name": "under",
"value": "ns2",
"id": 11189891,
"type": "NS"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 300,
"source": 1,
"name": "www",
"value": "2.2.3.6",
"id": 11189892,
"type": "A"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 300,
"source": 1,
"name": "www.sub",
"value": "2.2.3.6",
"id": 11189893,
"type": "A"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 300,
"source": 1,
"name": "ptr",
"value": "foo.bar.com.",
"id": 11189894,
"type": "PTR"
}],
"page": 0
}

+ 202
- 0
tests/test_octodns_provider_dnsmadeeasy.py View File

@ -0,0 +1,202 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from mock import Mock, call
from os.path import dirname, join
from requests import HTTPError
from requests_mock import ANY, mock as requests_mock
from unittest import TestCase
from octodns.record import Record
from octodns.provider.dnsmadeeasy import DnsMadeEasyClientNotFound, \
DnsMadeEasyProvider
from octodns.provider.yaml import YamlProvider
from octodns.zone import Zone
import json
class TestDnsMadeEasyProvider(TestCase):
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
# Our test suite differs a bit, add our NS and remove the simple one
expected.add_record(Record.new(expected, 'under', {
'ttl': 3600,
'type': 'NS',
'values': [
'ns1.unit.tests.',
'ns2.unit.tests.',
]
}))
for record in list(expected.records):
if record.name == 'sub' and record._type == 'NS':
expected._remove_record(record)
break
def test_populate(self):
provider = DnsMadeEasyProvider('test', 'api', 'secret')
# Bad auth
with requests_mock() as mock:
mock.get(ANY, status_code=401,
text='{"error": ["API key not found"]}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('Unauthorized', ctx.exception.message)
# Bad request
with requests_mock() as mock:
mock.get(ANY, status_code=400,
text='{"error": ["Rate limit exceeded"]}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('\n - Rate limit exceeded',
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=404,
text='<html><head></head><body></body></html>')
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(set(), zone.records)
# No diffs == no changes
with requests_mock() as mock:
base = 'https://api.dnsmadeeasy.com/V2.0/dns/managed'
with open('tests/fixtures/dnsmadeeasy-domains.json') as fh:
mock.get('{}{}'.format(base, '/'), text=fh.read())
with open('tests/fixtures/dnsmadeeasy-records.json') as fh:
mock.get('{}{}'.format(base, '/123123/records'),
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))
# 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEquals(13, len(again.records))
# bust the cache
del provider._zone_records[zone.name]
def test_apply(self):
# Create provider with sandbox enabled
provider = DnsMadeEasyProvider('test', 'api', 'secret', True)
resp = Mock()
resp.json = Mock()
provider._client._request = Mock(return_value=resp)
with open('tests/fixtures/dnsmadeeasy-domains.json') as fh:
domains = json.load(fh)
# non-existant domain, create everything
resp.json.side_effect = [
DnsMadeEasyClientNotFound, # no zone in populate
DnsMadeEasyClientNotFound, # no domain during apply
domains
]
plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 5
self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan))
provider._client._request.assert_has_calls([
# created the domain
call('POST', '/', data={'name': 'unit.tests'}),
# get all domains to build the cache
call('GET', '/'),
# created at least one of the record with expected data
call('POST', '/123123/records', data={
'name': '_srv._tcp',
'weight': 20,
'value': 'foo-1.unit.tests.',
'priority': 10,
'ttl': 600,
'type': 'SRV',
'port': 30
}),
])
self.assertEquals(25, provider._client._request.call_count)
provider._client._request.reset_mock()
# delete 1 and update 1
provider._client.records = Mock(return_value=[
{
'id': 11189897,
'name': 'www',
'value': '1.2.3.4',
'ttl': 300,
'type': 'A',
},
{
'id': 11189898,
'name': 'www',
'value': '2.2.3.4',
'ttl': 300,
'type': 'A',
},
{
'id': 11189899,
'name': 'ttl',
'value': '3.2.3.4',
'ttl': 600,
'type': 'A',
}
])
# 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.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('POST', '/123123/records', data={
'value': '3.2.3.4',
'type': 'A',
'name': 'ttl',
'ttl': 300
}),
call('DELETE', '/123123/records/11189899'),
call('DELETE', '/123123/records/11189897'),
call('DELETE', '/123123/records/11189898')
], any_order=True)

Loading…
Cancel
Save