Browse Source

Merge branch 'master' into googledns-semicolon-escapes

pull/194/head
Ross McFarland 8 years ago
committed by GitHub
parent
commit
fc4bf6b8ce
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 328 additions and 203 deletions
  1. +1
    -1
      CHANGELOG.md
  2. +1
    -0
      README.md
  3. +4
    -4
      docs/records.md
  4. +3
    -3
      octodns/cmds/report.py
  5. +1
    -1
      octodns/cmds/sync.py
  6. +2
    -2
      octodns/manager.py
  7. +8
    -3
      octodns/provider/azuredns.py
  8. +12
    -6
      octodns/provider/base.py
  9. +37
    -34
      octodns/provider/cloudflare.py
  10. +5
    -3
      octodns/provider/digitalocean.py
  11. +5
    -3
      octodns/provider/dnsimple.py
  12. +13
    -9
      octodns/provider/dyn.py
  13. +6
    -1
      octodns/provider/googlecloud.py
  14. +7
    -4
      octodns/provider/ns1.py
  15. +13
    -3
      octodns/provider/ovh.py
  16. +36
    -17
      octodns/provider/plan.py
  17. +9
    -6
      octodns/provider/powerdns.py
  18. +4
    -11
      octodns/provider/rackspace.py
  19. +8
    -5
      octodns/provider/route53.py
  20. +3
    -2
      octodns/provider/yaml.py
  21. +4
    -3
      octodns/record.py
  22. +4
    -1
      octodns/source/base.py
  23. +3
    -3
      octodns/zone.py
  24. +1
    -1
      script/release
  25. +34
    -13
      tests/test_octodns_plan.py
  26. +10
    -5
      tests/test_octodns_provider_azuredns.py
  27. +22
    -20
      tests/test_octodns_provider_base.py
  28. +8
    -5
      tests/test_octodns_provider_cloudflare.py
  29. +2
    -0
      tests/test_octodns_provider_digitalocean.py
  30. +2
    -0
      tests/test_octodns_provider_dnsimple.py
  31. +11
    -9
      tests/test_octodns_provider_dyn.py
  32. +13
    -9
      tests/test_octodns_provider_googlecloud.py
  33. +3
    -1
      tests/test_octodns_provider_ns1.py
  34. +19
    -7
      tests/test_octodns_provider_ovh.py
  35. +4
    -2
      tests/test_octodns_provider_powerdns.py
  36. +3
    -1
      tests/test_octodns_provider_rackspace.py
  37. +5
    -3
      tests/test_octodns_provider_route53.py
  38. +2
    -2
      tests/test_octodns_record.py

+ 1
- 1
CHANGELOG.md View File

@ -60,7 +60,7 @@ better in the future :fingers_crossed:
#### Miscellaneous #### Miscellaneous
* Use a 3rd party lib for nautrual sorting of keys, rather than my old
* Use a 3rd party lib for natural sorting of keys, rather than my old
implementation. Sorting can be disabled in the YamlProvider with implementation. Sorting can be disabled in the YamlProvider with
`enforce_order: False`. `enforce_order: False`.
* Semi-colon/escaping fixes and improvements. * Semi-colon/escaping fixes and improvements.


+ 1
- 0
README.md View File

@ -169,6 +169,7 @@ The above command pulled the existing data out of Route53 and placed the results
* ALIAS support varies a lot from provider to provider care should be taken to verify that your needs are met in detail. * ALIAS support varies a lot from provider to provider care should be taken to verify that your needs are met in detail.
* Dyn's UI doesn't allow editing or view of TTL, but the API accepts and stores the value provided, this value does not appear to be used when served * Dyn's UI doesn't allow editing or view of TTL, but the API accepts and stores the value provided, this value does not appear to be used when served
* Dnsimple's uses the configured TTL when serving things through the ALIAS, there's also a secondary TXT record created alongside the ALIAS that octoDNS ignores * Dnsimple's uses the configured TTL when serving things through the ALIAS, there's also a secondary TXT record created alongside the ALIAS that octoDNS ignores
* octoDNS itself supports non-ASCII character sets, but in testing Cloudflare is the only provider where that is currently functional end-to-end. Others have failures either in the client libraries or API calls
## Custom Sources and Providers ## Custom Sources and Providers


+ 4
- 4
docs/records.md View File

@ -53,11 +53,11 @@ The geo labels breakdown based on:
1. 1.
- 'AF': 14, # Continental Africa - 'AF': 14, # Continental Africa
- 'AN': 17, # Continental Antartica
- 'AS': 15, # Contentinal Asia
- 'EU': 13, # Contentinal Europe
- 'AN': 17, # Continental Antarctica
- 'AS': 15, # Continental Asia
- 'EU': 13, # Continental Europe
- 'NA': 11, # Continental North America - 'NA': 11, # Continental North America
- 'OC': 16, # Contentinal Austrailia/Oceania
- 'OC': 16, # Continental Australia/Oceania
- 'SA': 12, # Continental South America - 'SA': 12, # Continental South America
2. ISO Country Code https://en.wikipedia.org/wiki/ISO_3166-2 2. ISO Country Code https://en.wikipedia.org/wiki/ISO_3166-2


+ 3
- 3
octodns/cmds/report.py View File

@ -65,7 +65,7 @@ def main():
resolver = AsyncResolver(configure=False, resolver = AsyncResolver(configure=False,
num_workers=int(args.num_workers)) num_workers=int(args.num_workers))
if not ip_addr_re.match(server): if not ip_addr_re.match(server):
server = str(query(server, 'A')[0])
server = unicode(query(server, 'A')[0])
log.info('server=%s', server) log.info('server=%s', server)
resolver.nameservers = [server] resolver.nameservers = [server]
resolver.lifetime = int(args.timeout) resolver.lifetime = int(args.timeout)
@ -81,12 +81,12 @@ def main():
stdout.write(',') stdout.write(',')
stdout.write(record._type) stdout.write(record._type)
stdout.write(',') stdout.write(',')
stdout.write(str(record.ttl))
stdout.write(unicode(record.ttl))
compare = {} compare = {}
for future in futures: for future in futures:
stdout.write(',') stdout.write(',')
try: try:
answers = [str(r) for r in future.result()]
answers = [unicode(r) for r in future.result()]
except (NoAnswer, NoNameservers): except (NoAnswer, NoNameservers):
answers = ['*no answer*'] answers = ['*no answer*']
except NXDOMAIN: except NXDOMAIN:


+ 1
- 1
octodns/cmds/sync.py View File

@ -26,7 +26,7 @@ def main():
help='Limit sync to the specified zone(s)') help='Limit sync to the specified zone(s)')
# --sources isn't an option here b/c filtering sources out would be super # --sources isn't an option here b/c filtering sources out would be super
# dangerous since you could eaily end up with an empty zone and delete
# dangerous since you could easily end up with an empty zone and delete
# everything, or even just part of things when there are multiple sources # everything, or even just part of things when there are multiple sources
parser.add_argument('--target', default=[], action='append', parser.add_argument('--target', default=[], action='append',


+ 2
- 2
octodns/manager.py View File

@ -51,7 +51,7 @@ class MakeThreadFuture(object):
class MainThreadExecutor(object): class MainThreadExecutor(object):
''' '''
Dummy executor that runs things on the main thread during the involcation
Dummy executor that runs things on the main thread during the invocation
of submit, but still returns a future object with the result. This allows of submit, but still returns a future object with the result. This allows
code to be written to handle async, even in the case where we don't want to code to be written to handle async, even in the case where we don't want to
use multiple threads/workers and would prefer that things flow as if use multiple threads/workers and would prefer that things flow as if
@ -361,7 +361,7 @@ class Manager(object):
plan = target.plan(zone) plan = target.plan(zone)
if plan is None: if plan is None:
plan = Plan(zone, zone, [])
plan = Plan(zone, zone, [], False)
target.apply(plan) target.apply(plan)
def validate_configs(self): def validate_configs(self):


+ 8
- 3
octodns/provider/azuredns.py View File

@ -39,7 +39,7 @@ class _AzureRecord(object):
} }
def __init__(self, resource_group, record, delete=False): def __init__(self, resource_group, record, delete=False):
'''Contructor for _AzureRecord.
'''Constructor for _AzureRecord.
Notes on Azure records: An Azure record set has the form Notes on Azure records: An Azure record set has the form
RecordSet(name=<...>, type=<...>, arecords=[...], aaaa_records, ..) RecordSet(name=<...>, type=<...>, arecords=[...], aaaa_records, ..)
@ -222,7 +222,7 @@ class AzureProvider(BaseProvider):
azuredns: azuredns:
class: octodns.provider.azuredns.AzureProvider class: octodns.provider.azuredns.AzureProvider
client_id: env/AZURE_APPLICATION_ID client_id: env/AZURE_APPLICATION_ID
key: env/AZURE_AUTHENICATION_KEY
key: env/AZURE_AUTHENTICATION_KEY
directory_id: env/AZURE_DIRECTORY_ID directory_id: env/AZURE_DIRECTORY_ID
sub_id: env/AZURE_SUBSCRIPTION_ID sub_id: env/AZURE_SUBSCRIPTION_ID
resource_group: 'TestResource1' resource_group: 'TestResource1'
@ -322,6 +322,8 @@ class AzureProvider(BaseProvider):
:type return: void :type return: void
''' '''
self.log.debug('populate: name=%s', zone.name) self.log.debug('populate: name=%s', zone.name)
exists = False
before = len(zone.records) before = len(zone.records)
zone_name = zone.name[:len(zone.name) - 1] zone_name = zone.name[:len(zone.name) - 1]
@ -331,6 +333,7 @@ class AzureProvider(BaseProvider):
_records = set() _records = set()
records = self._dns_client.record_sets.list_by_dns_zone records = self._dns_client.record_sets.list_by_dns_zone
if self._check_zone(zone_name): if self._check_zone(zone_name):
exists = True
for azrecord in records(self._resource_group, zone_name): for azrecord in records(self._resource_group, zone_name):
if _parse_azure_type(azrecord.type) in self.SUPPORTS: if _parse_azure_type(azrecord.type) in self.SUPPORTS:
_records.add(azrecord) _records.add(azrecord)
@ -344,7 +347,9 @@ class AzureProvider(BaseProvider):
record = Record.new(zone, record_name, data, source=self) record = Record.new(zone, record_name, data, source=self)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records', len(zone.records) - before)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _data_for_A(self, azrecord): def _data_for_A(self, azrecord):
return {'values': [ar.ipv4_address for ar in azrecord.arecords]} return {'values': [ar.ipv4_address for ar in azrecord.arecords]}


+ 12
- 6
octodns/provider/base.py View File

@ -17,7 +17,8 @@ class BaseProvider(BaseSource):
delete_pcent_threshold=Plan.MAX_SAFE_DELETE_PCENT): delete_pcent_threshold=Plan.MAX_SAFE_DELETE_PCENT):
super(BaseProvider, self).__init__(id) super(BaseProvider, self).__init__(id)
self.log.debug('__init__: id=%s, apply_disabled=%s, ' self.log.debug('__init__: id=%s, apply_disabled=%s, '
'update_pcent_threshold=%d, delete_pcent_threshold=%d',
'update_pcent_threshold=%.2f'
'delete_pcent_threshold=%.2f',
id, id,
apply_disabled, apply_disabled,
update_pcent_threshold, update_pcent_threshold,
@ -29,14 +30,14 @@ class BaseProvider(BaseSource):
def _include_change(self, change): def _include_change(self, change):
''' '''
An opportunity for providers to filter out false positives due to An opportunity for providers to filter out false positives due to
pecularities in their implementation. E.g. minimum TTLs.
peculiarities in their implementation. E.g. minimum TTLs.
''' '''
return True return True
def _extra_changes(self, existing, changes): def _extra_changes(self, existing, changes):
''' '''
An opportunity for providers to add extra changes to the plan that are An opportunity for providers to add extra changes to the plan that are
necessary to update ancilary record data or configure the zone. E.g.
necessary to update ancillary record data or configure the zone. E.g.
base NS records. base NS records.
''' '''
return [] return []
@ -45,7 +46,12 @@ class BaseProvider(BaseSource):
self.log.info('plan: desired=%s', desired.name) self.log.info('plan: desired=%s', desired.name)
existing = Zone(desired.name, desired.sub_zones) existing = Zone(desired.name, desired.sub_zones)
self.populate(existing, target=True, lenient=True)
exists = self.populate(existing, target=True, lenient=True)
if exists is None:
# If your code gets this warning see Source.populate for more
# information
self.log.warn('Provider %s used in target mode did not return '
'exists', self.id)
# compute the changes at the zone/record level # compute the changes at the zone/record level
changes = existing.changes(desired, self) changes = existing.changes(desired, self)
@ -61,11 +67,11 @@ class BaseProvider(BaseSource):
extra = self._extra_changes(existing, changes) extra = self._extra_changes(existing, changes)
if extra: if extra:
self.log.info('plan: extra changes\n %s', '\n ' self.log.info('plan: extra changes\n %s', '\n '
.join([str(c) for c in extra]))
.join([unicode(c) for c in extra]))
changes += extra changes += extra
if changes: if changes:
plan = Plan(existing, desired, changes,
plan = Plan(existing, desired, changes, exists,
self.update_pcent_threshold, self.update_pcent_threshold,
self.delete_pcent_threshold) self.delete_pcent_threshold)
self.log.info('plan: %s', plan) self.log.info('plan: %s', plan)


+ 37
- 34
octodns/provider/cloudflare.py View File

@ -210,13 +210,29 @@ class CloudflareProvider(BaseProvider):
return self._zone_records[zone.name] return self._zone_records[zone.name]
def _record_for(self, zone, name, _type, records, lenient):
# rewrite Cloudflare proxied records
if self.cdn and records[0]['proxied']:
data = self._data_for_cdn(name, _type, records)
else:
# Cloudflare supports ALIAS semantics with root CNAMEs
if _type == 'CNAME' and name == '':
_type = 'ALIAS'
data_for = getattr(self, '_data_for_{}'.format(_type))
data = data_for(_type, records)
return Record.new(zone, name, data, source=self, lenient=lenient)
def populate(self, zone, target=False, lenient=False): def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient) target, lenient)
exists = False
before = len(zone.records) before = len(zone.records)
records = self.zone_records(zone) records = self.zone_records(zone)
if records: if records:
exists = True
values = defaultdict(lambda: defaultdict(list)) values = defaultdict(lambda: defaultdict(list))
for record in records: for record in records:
name = zone.hostname_from_fqdn(record['name']) name = zone.hostname_from_fqdn(record['name'])
@ -226,21 +242,8 @@ class CloudflareProvider(BaseProvider):
for name, types in values.items(): for name, types in values.items():
for _type, records in types.items(): for _type, records in types.items():
# rewrite Cloudflare proxied records
if self.cdn and records[0]['proxied']:
data = self._data_for_cdn(name, _type, records)
else:
# Cloudflare supports ALIAS semantics with root CNAMEs
if _type == 'CNAME' and name == '':
_type = 'ALIAS'
data_for = getattr(self, '_data_for_{}'.format(_type))
data = data_for(_type, records)
record = Record.new(zone, name, data, source=self,
lenient=lenient)
record = self._record_for(zone, name, _type, records,
lenient)
# only one rewrite is needed for names where the proxy is # only one rewrite is needed for names where the proxy is
# enabled at multiple records with a different type but # enabled at multiple records with a different type but
@ -252,14 +255,15 @@ class CloudflareProvider(BaseProvider):
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _include_change(self, change): def _include_change(self, change):
if isinstance(change, Update): if isinstance(change, Update):
existing = change.existing.data existing = change.existing.data
new = change.new.data new = change.new.data
new['ttl'] = max(120, new['ttl'])
new['ttl'] = max(self.MIN_TTL, new['ttl'])
if new == existing: if new == existing:
return False return False
@ -374,10 +378,6 @@ class CloudflareProvider(BaseProvider):
for c in self._gen_contents(change.new) for c in self._gen_contents(change.new)
} }
# We need a list of keys to consider for diffs, use the first content
# before we muck with anything
keys = existing_contents.values()[0].keys()
# Find the things we need to add # Find the things we need to add
adds = [] adds = []
for k, content in new_contents.items(): for k, content in new_contents.items():
@ -387,22 +387,25 @@ class CloudflareProvider(BaseProvider):
except KeyError: except KeyError:
adds.append(content) adds.append(content)
zone_id = self.zones[change.new.zone.name]
zone = change.new.zone
zone_id = self.zones[zone.name]
# Find things we need to remove # Find things we need to remove
name = change.new.fqdn[:-1]
hostname = zone.hostname_from_fqdn(change.new.fqdn[:-1])
_type = change.new._type _type = change.new._type
# OK, work through each record from the zone # OK, work through each record from the zone
for record in self.zone_records(change.new.zone):
if name == record['name'] and _type == record['type']:
# This is match for our name and type, we need to look at
# contents now, build a dict of the relevant keys and vals
content = {}
for k in keys:
content[k] = record[k]
# :-(
if _type in ('CNAME', 'MX', 'NS'):
content['content'] += '.'
for record in self.zone_records(zone):
name = zone.hostname_from_fqdn(record['name'])
# Use the _record_for so that we include all of standard
# conversion logic
r = self._record_for(zone, name, record['type'], [record], True)
if hostname == r.name and _type == r._type:
# Round trip the single value through a record to contents flow
# to get a consistent _gen_contents result that matches what
# went in to new_contents
content = self._gen_contents(r).next()
# If the hash of that dict isn't in new this record isn't # If the hash of that dict isn't in new this record isn't
# needed # needed
if self._hash_content(content) not in new_contents: if self._hash_content(content) not in new_contents:


+ 5
- 3
octodns/provider/digitalocean.py View File

@ -56,7 +56,7 @@ class DigitalOceanClient(object):
self._request('POST', '/domains', data={'name': name, self._request('POST', '/domains', data={'name': name,
'ip_address': '192.0.2.1'}) 'ip_address': '192.0.2.1'})
# After the zone is created, immeadiately delete the record
# After the zone is created, immediately delete the record
records = self.records(name) records = self.records(name)
for record in records: for record in records:
if record['name'] == '' and record['type'] == 'A': if record['name'] == '' and record['type'] == 'A':
@ -232,8 +232,10 @@ class DigitalOceanProvider(BaseProvider):
source=self, lenient=lenient) source=self, lenient=lenient)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
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): def _params_for_multiple(self, record):
for value in record.values: for value in record.values:


+ 5
- 3
octodns/provider/dnsimple.py View File

@ -160,7 +160,7 @@ class DnsimpleProvider(BaseProvider):
record['content'].split(' ', 5) record['content'].split(' ', 5)
except ValueError: except ValueError:
# their api will let you create invalid records, this # their api will let you create invalid records, this
# essnetially handles that by ignoring them for values
# essentially handles that by ignoring them for values
# purposes. That will cause updates to happen to delete them if # purposes. That will cause updates to happen to delete them if
# they shouldn't exist or update them if they're wrong # they shouldn't exist or update them if they're wrong
continue continue
@ -272,8 +272,10 @@ class DnsimpleProvider(BaseProvider):
source=self, lenient=lenient) source=self, lenient=lenient)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
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): def _params_for_multiple(self, record):
for value in record.values: for value in record.values:


+ 13
- 9
octodns/provider/dyn.py View File

@ -40,7 +40,7 @@ class _CachingDynZone(DynZone):
cls.log.debug('get: fetched') cls.log.debug('get: fetched')
except DynectGetError: except DynectGetError:
if not create: if not create:
cls.log.debug("get: does't exist")
cls.log.debug("get: doesn't exist")
return None return None
# this value shouldn't really matter, it's not tied to # this value shouldn't really matter, it's not tied to
# whois or anything # whois or anything
@ -129,11 +129,11 @@ class DynProvider(BaseProvider):
REGION_CODES = { REGION_CODES = {
'NA': 11, # Continental North America 'NA': 11, # Continental North America
'SA': 12, # Continental South America 'SA': 12, # Continental South America
'EU': 13, # Contentinal Europe
'EU': 13, # Continental Europe
'AF': 14, # Continental Africa 'AF': 14, # Continental Africa
'AS': 15, # Contentinal Asia
'OC': 16, # Contentinal Austrailia/Oceania
'AN': 17, # Continental Antartica
'AS': 15, # Continental Asia
'OC': 16, # Continental Australia/Oceania
'AN': 17, # Continental Antarctica
} }
_sess_create_lock = Lock() _sess_create_lock = Lock()
@ -166,7 +166,7 @@ class DynProvider(BaseProvider):
if DynectSession.get_session() is None: if DynectSession.get_session() is None:
# We need to create a new session for this thread and DynectSession # We need to create a new session for this thread and DynectSession
# creation is not thread-safe so we have to do the locking. If we # creation is not thread-safe so we have to do the locking. If we
# don't and multiple sessions start creattion before the the first
# don't and multiple sessions start creation before the the first
# has finished (long time b/c it makes http calls) the subsequent # has finished (long time b/c it makes http calls) the subsequent
# creates will blow away DynectSession._instances, potentially # creates will blow away DynectSession._instances, potentially
# multiple times if there are multiple creates in flight. Only the # multiple times if there are multiple creates in flight. Only the
@ -291,7 +291,7 @@ class DynProvider(BaseProvider):
try: try:
fqdn, _type = td.label.split(':', 1) fqdn, _type = td.label.split(':', 1)
except ValueError as e: except ValueError as e:
self.log.warn("Failed to load TraficDirector '%s': %s",
self.log.warn("Failed to load TrafficDirector '%s': %s",
td.label, e.message) td.label, e.message)
continue continue
tds[fqdn][_type] = td tds[fqdn][_type] = td
@ -353,6 +353,7 @@ class DynProvider(BaseProvider):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient) target, lenient)
exists = False
before = len(zone.records) before = len(zone.records)
self._check_dyn_sess() self._check_dyn_sess()
@ -360,10 +361,12 @@ class DynProvider(BaseProvider):
td_records = set() td_records = set()
if self.traffic_directors_enabled: if self.traffic_directors_enabled:
td_records = self._populate_traffic_directors(zone) td_records = self._populate_traffic_directors(zone)
exists = True
dyn_zone = _CachingDynZone.get(zone.name[:-1]) dyn_zone = _CachingDynZone.get(zone.name[:-1])
if dyn_zone: if dyn_zone:
exists = True
values = defaultdict(lambda: defaultdict(list)) values = defaultdict(lambda: defaultdict(list))
for _type, records in dyn_zone.get_all_records().items(): for _type, records in dyn_zone.get_all_records().items():
if _type == 'soa_records': if _type == 'soa_records':
@ -382,8 +385,9 @@ class DynProvider(BaseProvider):
if record not in td_records: if record not in td_records:
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _kwargs_for_A(self, record): def _kwargs_for_A(self, record):
return [{ return [{


+ 6
- 1
octodns/provider/googlecloud.py View File

@ -204,11 +204,14 @@ class GoogleCloudProvider(BaseProvider):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient) target, lenient)
exists = False
before = len(zone.records) before = len(zone.records)
gcloud_zone = self.gcloud_zones.get(zone.name) gcloud_zone = self.gcloud_zones.get(zone.name)
if gcloud_zone: if gcloud_zone:
exists = True
for gcloud_record in self._get_gcloud_records(gcloud_zone): for gcloud_record in self._get_gcloud_records(gcloud_zone):
if gcloud_record.record_type.upper() not in self.SUPPORTS: if gcloud_record.record_type.upper() not in self.SUPPORTS:
continue continue
@ -229,7 +232,9 @@ class GoogleCloudProvider(BaseProvider):
record = Record.new(zone, record_name, data, source=self) record = Record.new(zone, record_name, data, source=self)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records', len(zone.records) - before)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _data_for_A(self, gcloud_record): def _data_for_A(self, gcloud_record):
return { return {


+ 7
- 4
octodns/provider/ns1.py View File

@ -75,9 +75,9 @@ class Ns1Provider(BaseProvider):
else: else:
values.extend(answer['answer']) values.extend(answer['answer'])
codes.append([]) codes.append([])
values = [str(x) for x in values]
values = [unicode(x) for x in values]
geo = OrderedDict( geo = OrderedDict(
{str(k): [str(x) for x in v] for k, v in geo.items()}
{unicode(k): [unicode(x) for x in v] for k, v in geo.items()}
) )
data['values'] = values data['values'] = values
data['geo'] = geo data['geo'] = geo
@ -190,11 +190,13 @@ class Ns1Provider(BaseProvider):
nsone_zone = self._client.loadZone(zone.name[:-1]) nsone_zone = self._client.loadZone(zone.name[:-1])
records = nsone_zone.data['records'] records = nsone_zone.data['records']
geo_records = nsone_zone.search(has_geo=True) geo_records = nsone_zone.search(has_geo=True)
exists = True
except ResourceException as e: except ResourceException as e:
if e.message != self.ZONE_NOT_FOUND_MESSAGE: if e.message != self.ZONE_NOT_FOUND_MESSAGE:
raise raise
records = [] records = []
geo_records = [] geo_records = []
exists = False
before = len(zone.records) before = len(zone.records)
# geo information isn't returned from the main endpoint, so we need # geo information isn't returned from the main endpoint, so we need
@ -208,8 +210,9 @@ class Ns1Provider(BaseProvider):
source=self, lenient=lenient) source=self, lenient=lenient)
zone_hash[(_type, name)] = record zone_hash[(_type, name)] = record
[zone.add_record(r) for r in zone_hash.values()] [zone.add_record(r) for r in zone_hash.values()]
self.log.info('populate: found %s records',
len(zone.records) - before)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _params_for_A(self, record): def _params_for_A(self, record):
params = {'answers': record.values, 'ttl': record.ttl} params = {'answers': record.values, 'ttl': record.ttl}


+ 13
- 3
octodns/provider/ovh.py View File

@ -11,6 +11,7 @@ import logging
from collections import defaultdict from collections import defaultdict
import ovh import ovh
from ovh import ResourceNotFoundError
from octodns.record import Record from octodns.record import Record
from .base import BaseProvider from .base import BaseProvider
@ -33,6 +34,7 @@ class OvhProvider(BaseProvider):
""" """
SUPPORTS_GEO = False SUPPORTS_GEO = False
ZONE_NOT_FOUND_MESSAGE = 'This service does not exist'
# This variable is also used in populate method to filter which OVH record # This variable is also used in populate method to filter which OVH record
# types are supported by octodns # types are supported by octodns
@ -57,7 +59,14 @@ class OvhProvider(BaseProvider):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient) target, lenient)
zone_name = zone.name[:-1] zone_name = zone.name[:-1]
records = self.get_records(zone_name=zone_name)
try:
records = self.get_records(zone_name=zone_name)
exists = True
except ResourceNotFoundError as e:
if e.message != self.ZONE_NOT_FOUND_MESSAGE:
raise
exists = False
records = []
values = defaultdict(lambda: defaultdict(list)) values = defaultdict(lambda: defaultdict(list))
for record in records: for record in records:
@ -75,8 +84,9 @@ class OvhProvider(BaseProvider):
source=self, lenient=lenient) source=self, lenient=lenient)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _apply(self, plan): def _apply(self, plan):
desired = plan.desired desired = plan.desired


+ 36
- 17
octodns/provider/plan.py View File

@ -21,12 +21,13 @@ class Plan(object):
MAX_SAFE_DELETE_PCENT = .3 MAX_SAFE_DELETE_PCENT = .3
MIN_EXISTING_RECORDS = 10 MIN_EXISTING_RECORDS = 10
def __init__(self, existing, desired, changes,
def __init__(self, existing, desired, changes, exists,
update_pcent_threshold=MAX_SAFE_UPDATE_PCENT, update_pcent_threshold=MAX_SAFE_UPDATE_PCENT,
delete_pcent_threshold=MAX_SAFE_DELETE_PCENT): delete_pcent_threshold=MAX_SAFE_DELETE_PCENT):
self.existing = existing self.existing = existing
self.desired = desired self.desired = desired
self.changes = changes self.changes = changes
self.exists = exists
self.update_pcent_threshold = update_pcent_threshold self.update_pcent_threshold = update_pcent_threshold
self.delete_pcent_threshold = delete_pcent_threshold self.delete_pcent_threshold = delete_pcent_threshold
@ -60,17 +61,17 @@ class Plan(object):
delete_pcent = self.change_counts['Delete'] / existing_record_count delete_pcent = self.change_counts['Delete'] / existing_record_count
if update_pcent > self.update_pcent_threshold: if update_pcent > self.update_pcent_threshold:
raise UnsafePlan('Too many updates, {} is over {} percent'
raise UnsafePlan('Too many updates, {:.2f} is over {:.2f} %'
'({}/{})'.format( '({}/{})'.format(
update_pcent,
self.MAX_SAFE_UPDATE_PCENT * 100,
update_pcent * 100,
self.update_pcent_threshold * 100,
self.change_counts['Update'], self.change_counts['Update'],
existing_record_count)) existing_record_count))
if delete_pcent > self.delete_pcent_threshold: if delete_pcent > self.delete_pcent_threshold:
raise UnsafePlan('Too many deletes, {} is over {} percent'
raise UnsafePlan('Too many deletes, {:.2f} is over {:.2f} %'
'({}/{})'.format( '({}/{})'.format(
delete_pcent,
self.MAX_SAFE_DELETE_PCENT * 100,
delete_pcent * 100,
self.delete_pcent_threshold * 100,
self.change_counts['Delete'], self.change_counts['Delete'],
existing_record_count)) existing_record_count))
@ -123,6 +124,12 @@ class PlanLogger(_PlanOutput):
buf.write(' (') buf.write(' (')
buf.write(target) buf.write(target)
buf.write(')\n* ') buf.write(')\n* ')
if plan.exists is False:
buf.write('Create ')
buf.write(str(plan.desired))
buf.write('\n* ')
for change in plan.changes: for change in plan.changes:
buf.write(change.__repr__(leader='* ')) buf.write(change.__repr__(leader='* '))
buf.write('\n* ') buf.write('\n* ')
@ -140,11 +147,11 @@ class PlanLogger(_PlanOutput):
def _value_stringifier(record, sep): def _value_stringifier(record, sep):
try: try:
values = [str(v) for v in record.values]
values = [unicode(v) for v in record.values]
except AttributeError: except AttributeError:
values = [record.value] values = [record.value]
for code, gv in sorted(getattr(record, 'geo', {}).items()): for code, gv in sorted(getattr(record, 'geo', {}).items()):
vs = ', '.join([str(v) for v in gv.values])
vs = ', '.join([unicode(v) for v in gv.values])
values.append('{}: {}'.format(code, vs)) values.append('{}: {}'.format(code, vs))
return sep.join(values) return sep.join(values)
@ -168,6 +175,11 @@ class PlanMarkdown(_PlanOutput):
fh.write('| Operation | Name | Type | TTL | Value | Source |\n' fh.write('| Operation | Name | Type | TTL | Value | Source |\n'
'|--|--|--|--|--|--|\n') '|--|--|--|--|--|--|\n')
if plan.exists is False:
fh.write('| Create | ')
fh.write(str(plan.desired))
fh.write(' | | | | |\n')
for change in plan.changes: for change in plan.changes:
existing = change.existing existing = change.existing
new = change.new new = change.new
@ -181,7 +193,7 @@ class PlanMarkdown(_PlanOutput):
fh.write(' | ') fh.write(' | ')
# TTL # TTL
if existing: if existing:
fh.write(str(existing.ttl))
fh.write(unicode(existing.ttl))
fh.write(' | ') fh.write(' | ')
fh.write(_value_stringifier(existing, '; ')) fh.write(_value_stringifier(existing, '; '))
fh.write(' | |\n') fh.write(' | |\n')
@ -189,15 +201,16 @@ class PlanMarkdown(_PlanOutput):
fh.write('| | | | ') fh.write('| | | | ')
if new: if new:
fh.write(str(new.ttl))
fh.write(unicode(new.ttl))
fh.write(' | ') fh.write(' | ')
fh.write(_value_stringifier(new, '; ')) fh.write(_value_stringifier(new, '; '))
fh.write(' | ') fh.write(' | ')
fh.write(new.source.id)
if new.source:
fh.write(new.source.id)
fh.write(' |\n') fh.write(' |\n')
fh.write('\nSummary: ') fh.write('\nSummary: ')
fh.write(str(plan))
fh.write(unicode(plan))
fh.write('\n\n') fh.write('\n\n')
else: else:
fh.write('## No changes were planned\n') fh.write('## No changes were planned\n')
@ -229,6 +242,11 @@ class PlanHtml(_PlanOutput):
</tr> </tr>
''') ''')
if plan.exists is False:
fh.write(' <tr>\n <td>Create</td>\n <td colspan=5>')
fh.write(str(plan.desired))
fh.write('</td>\n </tr>\n')
for change in plan.changes: for change in plan.changes:
existing = change.existing existing = change.existing
new = change.new new = change.new
@ -243,7 +261,7 @@ class PlanHtml(_PlanOutput):
# TTL # TTL
if existing: if existing:
fh.write(' <td>') fh.write(' <td>')
fh.write(str(existing.ttl))
fh.write(unicode(existing.ttl))
fh.write('</td>\n <td>') fh.write('</td>\n <td>')
fh.write(_value_stringifier(existing, '<br/>')) fh.write(_value_stringifier(existing, '<br/>'))
fh.write('</td>\n <td></td>\n </tr>\n') fh.write('</td>\n <td></td>\n </tr>\n')
@ -252,15 +270,16 @@ class PlanHtml(_PlanOutput):
if new: if new:
fh.write(' <td>') fh.write(' <td>')
fh.write(str(new.ttl))
fh.write(unicode(new.ttl))
fh.write('</td>\n <td>') fh.write('</td>\n <td>')
fh.write(_value_stringifier(new, '<br/>')) fh.write(_value_stringifier(new, '<br/>'))
fh.write('</td>\n <td>') fh.write('</td>\n <td>')
fh.write(new.source.id)
if new.source:
fh.write(new.source.id)
fh.write('</td>\n </tr>\n') fh.write('</td>\n </tr>\n')
fh.write(' <tr>\n <td colspan=6>Summary: ') fh.write(' <tr>\n <td colspan=6>Summary: ')
fh.write(str(plan))
fh.write(unicode(plan))
fh.write('</td>\n </tr>\n</table>\n') fh.write('</td>\n </tr>\n</table>\n')
else: else:
fh.write('<b>No changes were planned</b>') fh.write('<b>No changes were planned</b>')

+ 9
- 6
octodns/provider/powerdns.py View File

@ -178,7 +178,7 @@ class PowerDnsBaseProvider(BaseProvider):
raise Exception('PowerDNS unauthorized host={}' raise Exception('PowerDNS unauthorized host={}'
.format(self.host)) .format(self.host))
elif e.response.status_code == 422: elif e.response.status_code == 422:
# 422 means powerdns doesn't know anything about the requsted
# 422 means powerdns doesn't know anything about the requested
# domain. We'll just ignore it here and leave the zone # domain. We'll just ignore it here and leave the zone
# untouched. # untouched.
pass pass
@ -187,8 +187,10 @@ class PowerDnsBaseProvider(BaseProvider):
raise raise
before = len(zone.records) before = len(zone.records)
exists = False
if resp: if resp:
exists = True
for rrset in resp.json()['rrsets']: for rrset in resp.json()['rrsets']:
_type = rrset['type'] _type = rrset['type']
if _type == 'SOA': if _type == 'SOA':
@ -199,8 +201,9 @@ class PowerDnsBaseProvider(BaseProvider):
source=self, lenient=lenient) source=self, lenient=lenient)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _records_for_multiple(self, record): def _records_for_multiple(self, record):
return [{'content': v, 'disabled': False} return [{'content': v, 'disabled': False}
@ -294,8 +297,8 @@ class PowerDnsBaseProvider(BaseProvider):
return [] return []
# sorting mostly to make things deterministic for testing, but in # 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.)
# theory it let us find what we're after quicker (though sorting would
# be more expensive.)
for record in sorted(existing.records): for record in sorted(existing.records):
if record == ns: if record == ns:
# We've found the top-level NS record, return any changes # We've found the top-level NS record, return any changes
@ -341,7 +344,7 @@ class PowerDnsBaseProvider(BaseProvider):
e.response.text) e.response.text)
raise raise
self.log.info('_apply: creating zone=%s', desired.name) self.log.info('_apply: creating zone=%s', desired.name)
# 422 means powerdns doesn't know anything about the requsted
# 422 means powerdns doesn't know anything about the requested
# domain. We'll try to create it with the correct records instead # domain. We'll try to create it with the correct records instead
# of update. Hopefully all the mods are creates :-) # of update. Hopefully all the mods are creates :-)
data = { data = {


+ 4
- 11
octodns/provider/rackspace.py View File

@ -130,17 +130,9 @@ class RackspaceProvider(BaseProvider):
def _delete(self, path, data=None): def _delete(self, path, data=None):
return self._request('DELETE', path, data=data) return self._request('DELETE', path, data=data)
@staticmethod
def _as_unicode(s, codec):
if not isinstance(s, unicode):
return unicode(s, codec)
return s
@classmethod @classmethod
def _key_for_record(cls, rs_record): def _key_for_record(cls, rs_record):
return cls._as_unicode(rs_record['type'], 'ascii'), \
cls._as_unicode(rs_record['name'], 'utf-8'), \
cls._as_unicode(rs_record['data'], 'utf-8')
return rs_record['type'], rs_record['name'], rs_record['data']
def _data_for_multiple(self, rrset): def _data_for_multiple(self, rrset):
return { return {
@ -208,7 +200,7 @@ class RackspaceProvider(BaseProvider):
raise Exception('Rackspace request unauthorized') raise Exception('Rackspace request unauthorized')
elif e.response.status_code == 404: elif e.response.status_code == 404:
# Zone not found leaves the zone empty instead of failing. # Zone not found leaves the zone empty instead of failing.
return
return False
raise raise
before = len(zone.records) before = len(zone.records)
@ -225,8 +217,9 @@ class RackspaceProvider(BaseProvider):
source=self) source=self)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
self.log.info('populate: found %s records, exists=True',
len(zone.records) - before) len(zone.records) - before)
return True
def _group_records(self, all_records): def _group_records(self, all_records):
records = defaultdict(lambda: defaultdict(list)) records = defaultdict(lambda: defaultdict(list))


+ 8
- 5
octodns/provider/route53.py View File

@ -61,7 +61,7 @@ class _Route53Record(object):
# NOTE: we're using __hash__ and __cmp__ methods that consider # NOTE: we're using __hash__ and __cmp__ methods that consider
# _Route53Records equivalent if they have the same class, fqdn, and _type. # _Route53Records equivalent if they have the same class, fqdn, and _type.
# Values are ignored. This is usful when computing diffs/changes.
# Values are ignored. This is useful when computing diffs/changes.
def __hash__(self): def __hash__(self):
'sub-classes should never use this method' 'sub-classes should never use this method'
@ -451,9 +451,11 @@ class Route53Provider(BaseProvider):
target, lenient) target, lenient)
before = len(zone.records) before = len(zone.records)
exists = False
zone_id = self._get_zone_id(zone.name) zone_id = self._get_zone_id(zone.name)
if zone_id: if zone_id:
exists = True
records = defaultdict(lambda: defaultdict(list)) records = defaultdict(lambda: defaultdict(list))
for rrset in self._load_records(zone_id): for rrset in self._load_records(zone_id):
record_name = zone.hostname_from_fqdn(rrset['Name']) record_name = zone.hostname_from_fqdn(rrset['Name'])
@ -483,8 +485,9 @@ class Route53Provider(BaseProvider):
lenient=lenient) lenient=lenient)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _gen_mods(self, action, records): def _gen_mods(self, action, records):
''' '''
@ -679,7 +682,7 @@ class Route53Provider(BaseProvider):
.get('CountryCode', False) == '*': .get('CountryCode', False) == '*':
# it's a default record # it's a default record
continue continue
# we expect a healtcheck now
# we expect a healthcheck now
try: try:
health_check_id = rrset['HealthCheckId'] health_check_id = rrset['HealthCheckId']
caller_ref = \ caller_ref = \
@ -730,7 +733,7 @@ class Route53Provider(BaseProvider):
batch_rs_count) batch_rs_count)
# send the batch # send the batch
self._really_apply(batch, zone_id) self._really_apply(batch, zone_id)
# start a new batch with the lefovers
# start a new batch with the leftovers
batch = mods batch = mods
batch_rs_count = mods_rs_count batch_rs_count = mods_rs_count


+ 3
- 2
octodns/provider/yaml.py View File

@ -52,7 +52,7 @@ class YamlProvider(BaseProvider):
if target: if target:
# When acting as a target we ignore any existing records so that we # When acting as a target we ignore any existing records so that we
# create a completely new copy # create a completely new copy
return
return False
before = len(zone.records) before = len(zone.records)
filename = join(self.directory, '{}yaml'.format(zone.name)) filename = join(self.directory, '{}yaml'.format(zone.name))
@ -69,8 +69,9 @@ class YamlProvider(BaseProvider):
lenient=lenient) lenient=lenient)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records',
self.log.info('populate: found %s records, exists=False',
len(zone.records) - before) len(zone.records) - before)
return False
def _apply(self, plan): def _apply(self, plan):
desired = plan.desired desired = plan.desired


+ 4
- 3
octodns/record.py View File

@ -122,7 +122,7 @@ class Record(object):
self.__class__.__name__, name) self.__class__.__name__, name)
self.zone = zone self.zone = zone
# force everything lower-case just to be safe # force everything lower-case just to be safe
self.name = str(name).lower() if name else name
self.name = unicode(name).lower() if name else name
self.source = source self.source = source
self.ttl = int(data['ttl']) self.ttl = int(data['ttl'])
@ -151,7 +151,7 @@ class Record(object):
# NOTE: we're using __hash__ and __cmp__ methods that consider Records # NOTE: we're using __hash__ and __cmp__ methods that consider Records
# equivalent if they have the same name & _type. Values are ignored. This # equivalent if they have the same name & _type. Values are ignored. This
# is usful when computing diffs/changes.
# is useful when computing diffs/changes.
def __hash__(self): def __hash__(self):
return '{}:{}'.format(self.name, self._type).__hash__() return '{}:{}'.format(self.name, self._type).__hash__()
@ -274,7 +274,8 @@ class _ValuesMixin(object):
return ret return ret
def __repr__(self): def __repr__(self):
values = "['{}']".format("', '".join([str(v) for v in self.values]))
values = "['{}']".format("', '".join([unicode(v)
for v in self.values]))
return '<{} {} {}, {}, {}>'.format(self.__class__.__name__, return '<{} {} {}, {}, {}>'.format(self.__class__.__name__,
self._type, self.ttl, self._type, self.ttl,
self.fqdn, values) self.fqdn, values)


+ 4
- 1
octodns/source/base.py View File

@ -22,7 +22,7 @@ class BaseSource(object):
def populate(self, zone, target=False, lenient=False): def populate(self, zone, target=False, lenient=False):
''' '''
Loads all zones the provider knows about
Loads all records the provider knows about for the provided zone
When `target` is True the populate call is being made to load the When `target` is True the populate call is being made to load the
current state of the provider. current state of the provider.
@ -31,6 +31,9 @@ class BaseSource(object):
do a "best effort" load of data. That will allow through some common, do a "best effort" load of data. That will allow through some common,
but not best practices stuff that we otherwise would reject. E.g. no but not best practices stuff that we otherwise would reject. E.g. no
trailing . or mising escapes for ;. trailing . or mising escapes for ;.
When target is True (loading current state) this method should return
True if the zone exists or False if it does not.
''' '''
raise NotImplementedError('Abstract base class, populate method ' raise NotImplementedError('Abstract base class, populate method '
'missing') 'missing')


+ 3
- 3
octodns/zone.py View File

@ -37,10 +37,10 @@ class Zone(object):
if not name[-1] == '.': if not name[-1] == '.':
raise Exception('Invalid zone name {}, missing ending dot' raise Exception('Invalid zone name {}, missing ending dot'
.format(name)) .format(name))
# Force everyting to lowercase just to be safe
self.name = str(name).lower() if name else name
# Force everything to lowercase just to be safe
self.name = unicode(name).lower() if name else name
self.sub_zones = sub_zones self.sub_zones = sub_zones
# We're grouping by node, it allows us to efficently search for
# We're grouping by node, it allows us to efficiently search for
# duplicates and detect when CNAMEs co-exist with other records # duplicates and detect when CNAMEs co-exist with other records
self._records = defaultdict(set) self._records = defaultdict(set)
# optional leading . to match empty hostname # optional leading . to match empty hostname


+ 1
- 1
script/release View File

@ -11,4 +11,4 @@ git tag -s v$VERSION -m "Release $VERSION"
git push origin v$VERSION git push origin v$VERSION
echo "Tagged and pushed v$VERSION" echo "Tagged and pushed v$VERSION"
python setup.py sdist upload python setup.py sdist upload
echo "Updloaded $VERSION"
echo "Uploaded $VERSION"

+ 34
- 13
tests/test_octodns_plan.py View File

@ -16,15 +16,6 @@ from octodns.zone import Zone
from helpers import SimpleProvider from helpers import SimpleProvider
class TestPlanLogger(TestCase):
def test_invalid_level(self):
with self.assertRaises(Exception) as ctx:
PlanLogger('invalid', 'not-a-level')
self.assertEquals('Unsupported level: not-a-level',
ctx.exception.message)
simple = SimpleProvider() simple = SimpleProvider()
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
existing = Record.new(zone, 'a', { existing = Record.new(zone, 'a', {
@ -48,15 +39,45 @@ create = Create(Record.new(zone, 'b', {
'type': 'CNAME', 'type': 'CNAME',
'value': 'foo.unit.tests.' 'value': 'foo.unit.tests.'
}, simple)) }, simple))
create2 = Create(Record.new(zone, 'c', {
'ttl': 60,
'type': 'CNAME',
'value': 'foo.unit.tests.'
}))
update = Update(existing, new) update = Update(existing, new)
delete = Delete(new) delete = Delete(new)
changes = [create, delete, update]
changes = [create, create2, delete, update]
plans = [ plans = [
(simple, Plan(zone, zone, changes)),
(simple, Plan(zone, zone, changes)),
(simple, Plan(zone, zone, changes, True)),
(simple, Plan(zone, zone, changes, False)),
] ]
class TestPlanLogger(TestCase):
def test_invalid_level(self):
with self.assertRaises(Exception) as ctx:
PlanLogger('invalid', 'not-a-level')
self.assertEquals('Unsupported level: not-a-level',
ctx.exception.message)
def test_create(self):
class MockLogger(object):
def __init__(self):
self.out = StringIO()
def log(self, level, msg):
self.out.write(msg)
log = MockLogger()
PlanLogger('logger').run(log, plans)
out = log.out.getvalue()
self.assertTrue('Summary: Creates=2, Updates=1, '
'Deletes=1, Existing Records=0' in out)
class TestPlanHtml(TestCase): class TestPlanHtml(TestCase):
log = getLogger('TestPlanHtml') log = getLogger('TestPlanHtml')
@ -69,7 +90,7 @@ class TestPlanHtml(TestCase):
out = StringIO() out = StringIO()
PlanHtml('html').run(plans, fh=out) PlanHtml('html').run(plans, fh=out)
out = out.getvalue() out = out.getvalue()
self.assertTrue(' <td colspan=6>Summary: Creates=1, Updates=1, '
self.assertTrue(' <td colspan=6>Summary: Creates=2, Updates=1, '
'Deletes=1, Existing Records=0</td>' in out) 'Deletes=1, Existing Records=0</td>' in out)


+ 10
- 5
tests/test_octodns_provider_azuredns.py View File

@ -302,7 +302,8 @@ class TestAzureDnsProvider(TestCase):
record_list = provider._dns_client.record_sets.list_by_dns_zone record_list = provider._dns_client.record_sets.list_by_dns_zone
record_list.return_value = rs record_list.return_value = rs
provider.populate(zone)
exists = provider.populate(zone)
self.assertTrue(exists)
self.assertEquals(len(zone.records), 16) self.assertEquals(len(zone.records), 16)
@ -338,8 +339,10 @@ class TestAzureDnsProvider(TestCase):
changes.append(Create(i)) changes.append(Create(i))
deletes.append(Delete(i)) deletes.append(Delete(i))
self.assertEquals(13, provider.apply(Plan(None, zone, changes)))
self.assertEquals(13, provider.apply(Plan(zone, zone, deletes)))
self.assertEquals(13, provider.apply(Plan(None, zone,
changes, True)))
self.assertEquals(13, provider.apply(Plan(zone, zone,
deletes, True)))
def test_create_zone(self): def test_create_zone(self):
provider = self._get_provider() provider = self._get_provider()
@ -354,7 +357,8 @@ class TestAzureDnsProvider(TestCase):
_get = provider._dns_client.zones.get _get = provider._dns_client.zones.get
_get.side_effect = CloudError(Mock(status=404), err_msg) _get.side_effect = CloudError(Mock(status=404), err_msg)
self.assertEquals(13, provider.apply(Plan(None, desired, changes)))
self.assertEquals(13, provider.apply(Plan(None, desired, changes,
True)))
def test_check_zone_no_create(self): def test_check_zone_no_create(self):
provider = self._get_provider() provider = self._get_provider()
@ -374,6 +378,7 @@ class TestAzureDnsProvider(TestCase):
_get = provider._dns_client.zones.get _get = provider._dns_client.zones.get
_get.side_effect = CloudError(Mock(status=404), err_msg) _get.side_effect = CloudError(Mock(status=404), err_msg)
provider.populate(Zone('unit3.test.', []))
exists = provider.populate(Zone('unit3.test.', []))
self.assertFalse(exists)
self.assertEquals(len(zone.records), 0) self.assertEquals(len(zone.records), 0)

+ 22
- 20
tests/test_octodns_provider_base.py View File

@ -63,14 +63,14 @@ class TestBaseProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
with self.assertRaises(NotImplementedError) as ctx: with self.assertRaises(NotImplementedError) as ctx:
HasSupportsGeo('hassupportesgeo').populate(zone)
HasSupportsGeo('hassupportsgeo').populate(zone)
self.assertEquals('Abstract base class, SUPPORTS property missing', self.assertEquals('Abstract base class, SUPPORTS property missing',
ctx.exception.message) ctx.exception.message)
class HasSupports(HasSupportsGeo): class HasSupports(HasSupportsGeo):
SUPPORTS = set(('A',)) SUPPORTS = set(('A',))
with self.assertRaises(NotImplementedError) as ctx: with self.assertRaises(NotImplementedError) as ctx:
HasSupports('hassupportes').populate(zone)
HasSupports('hassupports').populate(zone)
self.assertEquals('Abstract base class, populate method missing', self.assertEquals('Abstract base class, populate method missing',
ctx.exception.message) ctx.exception.message)
@ -94,7 +94,7 @@ class TestBaseProvider(TestCase):
'value': '1.2.3.4' 'value': '1.2.3.4'
})) }))
self.assertTrue(HasSupports('hassupportesgeo')
self.assertTrue(HasSupports('hassupportsgeo')
.supports(list(zone.records)[0])) .supports(list(zone.records)[0]))
plan = HasPopulate('haspopulate').plan(zone) plan = HasPopulate('haspopulate').plan(zone)
@ -153,7 +153,7 @@ class TestBaseProvider(TestCase):
def test_safe_none(self): def test_safe_none(self):
# No changes is safe # No changes is safe
Plan(None, None, []).raise_if_unsafe()
Plan(None, None, [], True).raise_if_unsafe()
def test_safe_creates(self): def test_safe_creates(self):
# Creates are safe when existing records is under MIN_EXISTING_RECORDS # Creates are safe when existing records is under MIN_EXISTING_RECORDS
@ -164,7 +164,8 @@ class TestBaseProvider(TestCase):
'type': 'A', 'type': 'A',
'value': '1.2.3.4', 'value': '1.2.3.4',
}) })
Plan(zone, zone, [Create(record) for i in range(10)]).raise_if_unsafe()
Plan(zone, zone, [Create(record) for i in range(10)], True) \
.raise_if_unsafe()
def test_safe_min_existing_creates(self): def test_safe_min_existing_creates(self):
# Creates are safe when existing records is over MIN_EXISTING_RECORDS # Creates are safe when existing records is over MIN_EXISTING_RECORDS
@ -177,13 +178,14 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), {
zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
})) }))
Plan(zone, zone, [Create(record) for i in range(10)]).raise_if_unsafe()
Plan(zone, zone, [Create(record) for i in range(10)], True) \
.raise_if_unsafe()
def test_safe_no_existing(self): def test_safe_no_existing(self):
# existing records fewer than MIN_EXISTING_RECORDS is safe # existing records fewer than MIN_EXISTING_RECORDS is safe
@ -195,7 +197,7 @@ class TestBaseProvider(TestCase):
}) })
updates = [Update(record, record), Update(record, record)] updates = [Update(record, record), Update(record, record)]
Plan(zone, zone, updates).raise_if_unsafe()
Plan(zone, zone, updates, True).raise_if_unsafe()
def test_safe_updates_min_existing(self): def test_safe_updates_min_existing(self):
# MAX_SAFE_UPDATE_PCENT+1 fails when more # MAX_SAFE_UPDATE_PCENT+1 fails when more
@ -208,7 +210,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), {
zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@ -219,7 +221,7 @@ class TestBaseProvider(TestCase):
Plan.MAX_SAFE_UPDATE_PCENT) + 1)] Plan.MAX_SAFE_UPDATE_PCENT) + 1)]
with self.assertRaises(UnsafePlan) as ctx: with self.assertRaises(UnsafePlan) as ctx:
Plan(zone, zone, changes).raise_if_unsafe()
Plan(zone, zone, changes, True).raise_if_unsafe()
self.assertTrue('Too many updates' in ctx.exception.message) self.assertTrue('Too many updates' in ctx.exception.message)
@ -234,7 +236,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), {
zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@ -243,7 +245,7 @@ class TestBaseProvider(TestCase):
for i in range(int(Plan.MIN_EXISTING_RECORDS * for i in range(int(Plan.MIN_EXISTING_RECORDS *
Plan.MAX_SAFE_UPDATE_PCENT))] Plan.MAX_SAFE_UPDATE_PCENT))]
Plan(zone, zone, changes).raise_if_unsafe()
Plan(zone, zone, changes, True).raise_if_unsafe()
def test_safe_deletes_min_existing(self): def test_safe_deletes_min_existing(self):
# MAX_SAFE_DELETE_PCENT+1 fails when more # MAX_SAFE_DELETE_PCENT+1 fails when more
@ -256,7 +258,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), {
zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@ -267,7 +269,7 @@ class TestBaseProvider(TestCase):
Plan.MAX_SAFE_DELETE_PCENT) + 1)] Plan.MAX_SAFE_DELETE_PCENT) + 1)]
with self.assertRaises(UnsafePlan) as ctx: with self.assertRaises(UnsafePlan) as ctx:
Plan(zone, zone, changes).raise_if_unsafe()
Plan(zone, zone, changes, True).raise_if_unsafe()
self.assertTrue('Too many deletes' in ctx.exception.message) self.assertTrue('Too many deletes' in ctx.exception.message)
@ -282,7 +284,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), {
zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@ -291,7 +293,7 @@ class TestBaseProvider(TestCase):
for i in range(int(Plan.MIN_EXISTING_RECORDS * for i in range(int(Plan.MIN_EXISTING_RECORDS *
Plan.MAX_SAFE_DELETE_PCENT))] Plan.MAX_SAFE_DELETE_PCENT))]
Plan(zone, zone, changes).raise_if_unsafe()
Plan(zone, zone, changes, True).raise_if_unsafe()
def test_safe_updates_min_existing_override(self): def test_safe_updates_min_existing_override(self):
safe_pcent = .4 safe_pcent = .4
@ -305,7 +307,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), {
zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@ -316,7 +318,7 @@ class TestBaseProvider(TestCase):
safe_pcent) + 1)] safe_pcent) + 1)]
with self.assertRaises(UnsafePlan) as ctx: with self.assertRaises(UnsafePlan) as ctx:
Plan(zone, zone, changes,
Plan(zone, zone, changes, True,
update_pcent_threshold=safe_pcent).raise_if_unsafe() update_pcent_threshold=safe_pcent).raise_if_unsafe()
self.assertTrue('Too many updates' in ctx.exception.message) self.assertTrue('Too many updates' in ctx.exception.message)
@ -333,7 +335,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), {
zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@ -344,7 +346,7 @@ class TestBaseProvider(TestCase):
safe_pcent) + 1)] safe_pcent) + 1)]
with self.assertRaises(UnsafePlan) as ctx: with self.assertRaises(UnsafePlan) as ctx:
Plan(zone, zone, changes,
Plan(zone, zone, changes, True,
delete_pcent_threshold=safe_pcent).raise_if_unsafe() delete_pcent_threshold=safe_pcent).raise_if_unsafe()
self.assertTrue('Too many deletes' in ctx.exception.message) self.assertTrue('Too many deletes' in ctx.exception.message)

+ 8
- 5
tests/test_octodns_provider_cloudflare.py View File

@ -166,6 +166,7 @@ class TestCloudflareProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
self.assertEquals(12, len(plan.changes)) self.assertEquals(12, len(plan.changes))
self.assertEquals(12, provider.apply(plan)) self.assertEquals(12, provider.apply(plan))
self.assertFalse(plan.exists)
provider._request.assert_has_calls([ provider._request.assert_has_calls([
# created the domain # created the domain
@ -285,6 +286,7 @@ class TestCloudflareProvider(TestCase):
# only see the delete & ttl update, below min-ttl is filtered out # only see the delete & ttl update, below min-ttl is filtered out
self.assertEquals(2, len(plan.changes)) self.assertEquals(2, len(plan.changes))
self.assertEquals(2, provider.apply(plan)) self.assertEquals(2, provider.apply(plan))
self.assertTrue(plan.exists)
# recreate for update, and deletes for the 2 parts of the other # recreate for update, and deletes for the 2 parts of the other
provider._request.assert_has_calls([ provider._request.assert_has_calls([
call('PUT', '/zones/ff12ab34cd5611334422ab3322997650/dns_records/' call('PUT', '/zones/ff12ab34cd5611334422ab3322997650/dns_records/'
@ -366,7 +368,7 @@ class TestCloudflareProvider(TestCase):
'values': ['2.2.2.2', '3.3.3.3', '4.4.4.4'], 'values': ['2.2.2.2', '3.3.3.3', '4.4.4.4'],
}) })
change = Update(existing, new) change = Update(existing, new)
plan = Plan(zone, zone, [change])
plan = Plan(zone, zone, [change], True)
provider._apply(plan) provider._apply(plan)
provider._request.assert_has_calls([ provider._request.assert_has_calls([
@ -451,7 +453,7 @@ class TestCloudflareProvider(TestCase):
'value': 'ns2.foo.bar.', 'value': 'ns2.foo.bar.',
}) })
change = Update(existing, new) change = Update(existing, new)
plan = Plan(zone, zone, [change])
plan = Plan(zone, zone, [change], True)
provider._apply(plan) provider._apply(plan)
provider._request.assert_has_calls([ provider._request.assert_has_calls([
@ -599,7 +601,8 @@ class TestCloudflareProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
# the two A records get merged into one CNAME record poining to the CDN
# the two A records get merged into one CNAME record pointing to
# the CDN.
self.assertEquals(3, len(zone.records)) self.assertEquals(3, len(zone.records))
record = list(zone.records)[0] record = list(zone.records)[0]
@ -621,7 +624,7 @@ class TestCloudflareProvider(TestCase):
self.assertEquals('a.unit.tests.cdn.cloudflare.net.', record.value) self.assertEquals('a.unit.tests.cdn.cloudflare.net.', record.value)
# CDN enabled records can't be updated, we don't know the real values # CDN enabled records can't be updated, we don't know the real values
# never point a Cloudflare record to itsself.
# never point a Cloudflare record to itself.
wanted = Zone('unit.tests.', []) wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, 'cname', { wanted.add_record(Record.new(wanted, 'cname', {
'ttl': 300, 'ttl': 300,
@ -676,7 +679,7 @@ class TestCloudflareProvider(TestCase):
self.assertEquals('unit.tests.cdn.cloudflare.net.', record.value) self.assertEquals('unit.tests.cdn.cloudflare.net.', record.value)
# CDN enabled records can't be updated, we don't know the real values # CDN enabled records can't be updated, we don't know the real values
# never point a Cloudflare record to itsself.
# never point a Cloudflare record to itself.
wanted = Zone('unit.tests.', []) wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, '', { wanted.add_record(Record.new(wanted, '', {
'ttl': 300, 'ttl': 300,


+ 2
- 0
tests/test_octodns_provider_digitalocean.py View File

@ -165,6 +165,7 @@ class TestDigitalOceanProvider(TestCase):
n = len(self.expected.records) - 7 n = len(self.expected.records) - 7
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
self.assertFalse(plan.exists)
provider._client._request.assert_has_calls([ provider._client._request.assert_has_calls([
# created the domain # created the domain
@ -225,6 +226,7 @@ class TestDigitalOceanProvider(TestCase):
})) }))
plan = provider.plan(wanted) plan = provider.plan(wanted)
self.assertTrue(plan.exists)
self.assertEquals(2, len(plan.changes)) self.assertEquals(2, len(plan.changes))
self.assertEquals(2, provider.apply(plan)) self.assertEquals(2, provider.apply(plan))
# recreate for update, and delete for the 2 parts of the other # recreate for update, and delete for the 2 parts of the other


+ 2
- 0
tests/test_octodns_provider_dnsimple.py View File

@ -133,6 +133,7 @@ class TestDnsimpleProvider(TestCase):
n = len(self.expected.records) - 3 n = len(self.expected.records) - 3
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
self.assertFalse(plan.exists)
provider._client._request.assert_has_calls([ provider._client._request.assert_has_calls([
# created the domain # created the domain
@ -186,6 +187,7 @@ class TestDnsimpleProvider(TestCase):
})) }))
plan = provider.plan(wanted) plan = provider.plan(wanted)
self.assertTrue(plan.exists)
self.assertEquals(2, len(plan.changes)) self.assertEquals(2, len(plan.changes))
self.assertEquals(2, provider.apply(plan)) self.assertEquals(2, provider.apply(plan))
# recreate for update, and deletes for the 2 parts of the other # recreate for update, and deletes for the 2 parts of the other


+ 11
- 9
tests/test_octodns_provider_dyn.py View File

@ -430,6 +430,7 @@ class TestDynProvider(TestCase):
update_mock.assert_not_called() update_mock.assert_not_called()
provider.apply(plan) provider.apply(plan)
update_mock.assert_called() update_mock.assert_called()
self.assertFalse(plan.exists)
add_mock.assert_called() add_mock.assert_called()
# Once for each dyn record (8 Records, 2 of which have dual values) # Once for each dyn record (8 Records, 2 of which have dual values)
self.assertEquals(15, len(add_mock.call_args_list)) self.assertEquals(15, len(add_mock.call_args_list))
@ -474,6 +475,7 @@ class TestDynProvider(TestCase):
plan = provider.plan(new) plan = provider.plan(new)
provider.apply(plan) provider.apply(plan)
update_mock.assert_called() update_mock.assert_called()
self.assertTrue(plan.exists)
# we expect 4 deletes, 2 from actual deletes and 2 from # we expect 4 deletes, 2 from actual deletes and 2 from
# updates which delete and recreate # updates which delete and recreate
self.assertEquals(4, len(delete_mock.call_args_list)) self.assertEquals(4, len(delete_mock.call_args_list))
@ -491,7 +493,7 @@ class TestDynProviderGeo(TestCase):
traffic_director_response = loads(fh.read()) traffic_director_response = loads(fh.read())
@property @property
def traffic_directors_reponse(self):
def traffic_directors_response(self):
return { return {
'data': [{ 'data': [{
'active': 'Y', 'active': 'Y',
@ -607,7 +609,7 @@ class TestDynProviderGeo(TestCase):
mock.side_effect = [{'data': []}] mock.side_effect = [{'data': []}]
self.assertEquals({}, provider.traffic_directors) self.assertEquals({}, provider.traffic_directors)
# a supported td and an ingored one
# a supported td and an ignored one
response = { response = {
'data': [{ 'data': [{
'active': 'Y', 'active': 'Y',
@ -650,7 +652,7 @@ class TestDynProviderGeo(TestCase):
set(tds.keys())) set(tds.keys()))
self.assertEquals(['A'], tds['unit.tests.'].keys()) self.assertEquals(['A'], tds['unit.tests.'].keys())
self.assertEquals(['A'], tds['geo.unit.tests.'].keys()) self.assertEquals(['A'], tds['geo.unit.tests.'].keys())
provider.log.warn.assert_called_with("Failed to load TraficDirector "
provider.log.warn.assert_called_with("Failed to load TrafficDirector "
"'%s': %s", 'something else', "'%s': %s", 'something else',
'need more than 1 value to ' 'need more than 1 value to '
'unpack') 'unpack')
@ -758,7 +760,7 @@ class TestDynProviderGeo(TestCase):
# only traffic director # only traffic director
mock.side_effect = [ mock.side_effect = [
# get traffic directors # get traffic directors
self.traffic_directors_reponse,
self.traffic_directors_response,
# get traffic director # get traffic director
self.traffic_director_response, self.traffic_director_response,
# get zone # get zone
@ -809,7 +811,7 @@ class TestDynProviderGeo(TestCase):
# both traffic director and regular, regular is ignored # both traffic director and regular, regular is ignored
mock.side_effect = [ mock.side_effect = [
# get traffic directors # get traffic directors
self.traffic_directors_reponse,
self.traffic_directors_response,
# get traffic director # get traffic director
self.traffic_director_response, self.traffic_director_response,
# get zone # get zone
@ -859,7 +861,7 @@ class TestDynProviderGeo(TestCase):
# busted traffic director # busted traffic director
mock.side_effect = [ mock.side_effect = [
# get traffic directors # get traffic directors
self.traffic_directors_reponse,
self.traffic_directors_response,
# get traffic director # get traffic director
busted_traffic_director_response, busted_traffic_director_response,
# get zone # get zone
@ -913,7 +915,7 @@ class TestDynProviderGeo(TestCase):
Delete(geo), Delete(geo),
Delete(regular), Delete(regular),
] ]
plan = Plan(None, desired, changes)
plan = Plan(None, desired, changes, True)
provider._apply(plan) provider._apply(plan)
mock.assert_has_calls([ mock.assert_has_calls([
call('/Zone/unit.tests/', 'GET', {}), call('/Zone/unit.tests/', 'GET', {}),
@ -932,14 +934,14 @@ class TestDynProviderGeo(TestCase):
provider = DynProvider('test', 'cust', 'user', 'pass', provider = DynProvider('test', 'cust', 'user', 'pass',
traffic_directors_enabled=True) traffic_directors_enabled=True)
# will be tested seperately
# will be tested separately
provider._mod_rulesets = MagicMock() provider._mod_rulesets = MagicMock()
mock.side_effect = [ mock.side_effect = [
# create traffic director # create traffic director
self.traffic_director_response, self.traffic_director_response,
# get traffic directors # get traffic directors
self.traffic_directors_reponse
self.traffic_directors_response
] ]
provider._mod_geo_Create(None, Create(self.geo_record)) provider._mod_geo_Create(None, Create(self.geo_record))
# td now lives in cache # td now lives in cache


+ 13
- 9
tests/test_octodns_provider_googlecloud.py View File

@ -263,7 +263,8 @@ class TestGoogleCloudProvider(TestCase):
provider.apply(Plan( provider.apply(Plan(
existing=[update_existing_r, delete_r], existing=[update_existing_r, delete_r],
desired=desired, desired=desired,
changes=changes
changes=changes,
exists=True
)) ))
calls_mock = gcloud_zone_mock.changes.return_value calls_mock = gcloud_zone_mock.changes.return_value
@ -295,7 +296,8 @@ class TestGoogleCloudProvider(TestCase):
provider.apply(Plan( provider.apply(Plan(
existing=[update_existing_r, delete_r], existing=[update_existing_r, delete_r],
desired=desired, desired=desired,
changes=changes
changes=changes,
exists=True
)) ))
unsupported_change = Mock() unsupported_change = Mock()
@ -357,15 +359,17 @@ class TestGoogleCloudProvider(TestCase):
"unit.tests.") "unit.tests.")
test_zone = Zone('unit.tests.', []) test_zone = Zone('unit.tests.', [])
provider.populate(test_zone)
exists = provider.populate(test_zone)
self.assertTrue(exists)
# test_zone gets fed the same records as zone does, except it's in # test_zone gets fed the same records as zone does, except it's in
# the format returned by google API, so after populate they should look # the format returned by google API, so after populate they should look
# excactly the same.
# exactly the same.
self.assertEqual(test_zone.records, zone.records) self.assertEqual(test_zone.records, zone.records)
test_zone2 = Zone('nonexistant.zone.', [])
provider.populate(test_zone2, False, False)
test_zone2 = Zone('nonexistent.zone.', [])
exists = provider.populate(test_zone2, False, False)
self.assertFalse(exists)
self.assertEqual(len(test_zone2.records), 0, self.assertEqual(len(test_zone2.records), 0,
msg="Zone should not get records from wrong domain") msg="Zone should not get records from wrong domain")
@ -401,8 +405,8 @@ class TestGoogleCloudProvider(TestCase):
provider.gcloud_client.list_zones = Mock( provider.gcloud_client.list_zones = Mock(
return_value=DummyIterator([])) return_value=DummyIterator([]))
self.assertIsNone(provider.gcloud_zones.get("nonexistant.xone"),
msg="Check that nonexistant zones return None when"
self.assertIsNone(provider.gcloud_zones.get("nonexistent.zone"),
msg="Check that nonexistent zones return None when"
"there's no create=True flag") "there's no create=True flag")
def test__get_rrsets(self): def test__get_rrsets(self):
@ -423,7 +427,7 @@ class TestGoogleCloudProvider(TestCase):
provider.gcloud_client.list_zones = Mock( provider.gcloud_client.list_zones = Mock(
return_value=DummyIterator([])) return_value=DummyIterator([]))
mock_zone = provider._create_gcloud_zone("nonexistant.zone.mock")
mock_zone = provider._create_gcloud_zone("nonexistent.zone.mock")
mock_zone.create.assert_called() mock_zone.create.assert_called()
provider.gcloud_client.zone.assert_called() provider.gcloud_client.zone.assert_called()


+ 3
- 1
tests/test_octodns_provider_ns1.py View File

@ -196,9 +196,10 @@ class TestNs1Provider(TestCase):
load_mock.side_effect = \ load_mock.side_effect = \
ResourceException('server error: zone not found') ResourceException('server error: zone not found')
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone)
exists = provider.populate(zone)
self.assertEquals(set(), zone.records) self.assertEquals(set(), zone.records)
self.assertEquals(('unit.tests',), load_mock.call_args[0]) self.assertEquals(('unit.tests',), load_mock.call_args[0])
self.assertFalse(exists)
# Existing zone w/o records # Existing zone w/o records
load_mock.reset_mock() load_mock.reset_mock()
@ -269,6 +270,7 @@ class TestNs1Provider(TestCase):
# everything except the root NS # everything except the root NS
expected_n = len(self.expected) - 1 expected_n = len(self.expected) - 1
self.assertEquals(expected_n, len(plan.changes)) self.assertEquals(expected_n, len(plan.changes))
self.assertTrue(plan.exists)
# Fails, general error # Fails, general error
load_mock.reset_mock() load_mock.reset_mock()


+ 19
- 7
tests/test_octodns_provider_ovh.py View File

@ -8,7 +8,7 @@ from __future__ import absolute_import, division, print_function, \
from unittest import TestCase from unittest import TestCase
from mock import patch, call from mock import patch, call
from ovh import APIError
from ovh import APIError, ResourceNotFoundError, InvalidCredential
from octodns.provider.ovh import OvhProvider from octodns.provider.ovh import OvhProvider
from octodns.record import Record from octodns.record import Record
@ -199,14 +199,14 @@ class TestOvhProvider(TestCase):
api_record.append({ api_record.append({
'fieldType': 'SPF', 'fieldType': 'SPF',
'ttl': 1000, 'ttl': 1000,
'target': 'v=spf1 include:unit.texts.rerirect ~all',
'target': 'v=spf1 include:unit.texts.redirect ~all',
'subDomain': '', 'subDomain': '',
'id': 13 'id': 13
}) })
expected.add(Record.new(zone, '', { expected.add(Record.new(zone, '', {
'ttl': 1000, 'ttl': 1000,
'type': 'SPF', 'type': 'SPF',
'value': 'v=spf1 include:unit.texts.rerirect ~all'
'value': 'v=spf1 include:unit.texts.redirect ~all'
})) }))
# SSHFP # SSHFP
@ -307,18 +307,30 @@ class TestOvhProvider(TestCase):
with patch.object(provider._client, 'get') as get_mock: with patch.object(provider._client, 'get') as get_mock:
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
get_mock.side_effect = APIError('boom')
get_mock.side_effect = ResourceNotFoundError('boom')
with self.assertRaises(APIError) as ctx: with self.assertRaises(APIError) as ctx:
provider.populate(zone) provider.populate(zone)
self.assertEquals(get_mock.side_effect, ctx.exception) self.assertEquals(get_mock.side_effect, ctx.exception)
with patch.object(provider._client, 'get') as get_mock:
get_mock.side_effect = InvalidCredential('boom')
with self.assertRaises(APIError) as ctx:
provider.populate(zone)
self.assertEquals(get_mock.side_effect, ctx.exception)
zone = Zone('unit.tests.', [])
get_mock.side_effect = ResourceNotFoundError('This service does '
'not exist')
exists = provider.populate(zone)
self.assertEquals(set(), zone.records)
self.assertFalse(exists)
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
get_returns = [[record['id'] for record in self.api_record]] get_returns = [[record['id'] for record in self.api_record]]
get_returns += self.api_record get_returns += self.api_record
get_mock.side_effect = get_returns get_mock.side_effect = get_returns
provider.populate(zone)
exists = provider.populate(zone)
self.assertEquals(self.expected, zone.records) self.assertEquals(self.expected, zone.records)
self.assertTrue(exists)
@patch('ovh.Client') @patch('ovh.Client')
def test_is_valid_dkim(self, client_mock): def test_is_valid_dkim(self, client_mock):
@ -404,7 +416,7 @@ class TestOvhProvider(TestCase):
call(u'/domain/zone/unit.tests/record', fieldType=u'SPF', call(u'/domain/zone/unit.tests/record', fieldType=u'SPF',
subDomain=u'', ttl=1000, subDomain=u'', ttl=1000,
target=u'v=spf1 include:unit.texts.' target=u'v=spf1 include:unit.texts.'
u'rerirect ~all',
u'redirect ~all',
), ),
call(u'/domain/zone/unit.tests/record', fieldType=u'A', call(u'/domain/zone/unit.tests/record', fieldType=u'A',
subDomain='sub', target=u'1.2.3.4', ttl=200), subDomain='sub', target=u'1.2.3.4', ttl=200),


+ 4
- 2
tests/test_octodns_provider_powerdns.py View File

@ -100,12 +100,13 @@ class TestPowerDnsProvider(TestCase):
# No existing records -> creates for every record in expected # No existing records -> creates for every record in expected
with requests_mock() as mock: with requests_mock() as mock:
mock.get(ANY, status_code=200, text=EMPTY_TEXT) mock.get(ANY, 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) mock.patch(ANY, status_code=201, text=assert_rrsets_callback)
plan = provider.plan(expected) plan = provider.plan(expected)
self.assertEquals(expected_n, len(plan.changes)) self.assertEquals(expected_n, len(plan.changes))
self.assertEquals(expected_n, provider.apply(plan)) self.assertEquals(expected_n, provider.apply(plan))
self.assertTrue(plan.exists)
# Non-existent zone -> creates for every record in expected # Non-existent zone -> creates for every record in expected
# OMG this is fucking ugly, probably better to ditch requests_mocks and # OMG this is fucking ugly, probably better to ditch requests_mocks and
@ -118,12 +119,13 @@ class TestPowerDnsProvider(TestCase):
mock.get(ANY, status_code=422, text='') mock.get(ANY, status_code=422, text='')
# patch 422's, unknown zone # patch 422's, unknown zone
mock.patch(ANY, status_code=422, text=dumps(not_found)) 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) mock.post(ANY, status_code=201, text=assert_rrsets_callback)
plan = provider.plan(expected) plan = provider.plan(expected)
self.assertEquals(expected_n, len(plan.changes)) self.assertEquals(expected_n, len(plan.changes))
self.assertEquals(expected_n, provider.apply(plan)) self.assertEquals(expected_n, provider.apply(plan))
self.assertFalse(plan.exists)
with requests_mock() as mock: with requests_mock() as mock:
# get 422's, unknown zone # get 422's, unknown zone


+ 3
- 1
tests/test_octodns_provider_rackspace.py View File

@ -73,9 +73,10 @@ class TestRackspaceProvider(TestCase):
json={'error': "Could not find domain 'unit.tests.'"}) json={'error': "Could not find domain 'unit.tests.'"})
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
self.provider.populate(zone)
exists = self.provider.populate(zone)
self.assertEquals(set(), zone.records) self.assertEquals(set(), zone.records)
self.assertTrue(mock.called_once) self.assertTrue(mock.called_once)
self.assertFalse(exists)
def test_multipage_populate(self): def test_multipage_populate(self):
with requests_mock() as mock: with requests_mock() as mock:
@ -109,6 +110,7 @@ class TestRackspaceProvider(TestCase):
plan = self.provider.plan(expected) plan = self.provider.plan(expected)
self.assertTrue(mock.called) self.assertTrue(mock.called)
self.assertTrue(plan.exists)
# OctoDNS does not propagate top-level NS records. # OctoDNS does not propagate top-level NS records.
self.assertEquals(1, len(plan.changes)) self.assertEquals(1, len(plan.changes))


+ 5
- 3
tests/test_octodns_provider_route53.py View File

@ -331,9 +331,9 @@ class TestRoute53Provider(TestCase):
stubber.assert_no_pending_responses() stubber.assert_no_pending_responses()
# Populate a zone that doesn't exist # Populate a zone that doesn't exist
noexist = Zone('does.not.exist.', [])
provider.populate(noexist)
self.assertEquals(set(), noexist.records)
nonexistent = Zone('does.not.exist.', [])
provider.populate(nonexistent)
self.assertEquals(set(), nonexistent.records)
def test_sync(self): def test_sync(self):
provider, stubber = self._get_stubbed_provider() provider, stubber = self._get_stubbed_provider()
@ -361,6 +361,7 @@ class TestRoute53Provider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
self.assertEquals(9, len(plan.changes)) self.assertEquals(9, len(plan.changes))
self.assertTrue(plan.exists)
for change in plan.changes: for change in plan.changes:
self.assertIsInstance(change, Create) self.assertIsInstance(change, Create)
stubber.assert_no_pending_responses() stubber.assert_no_pending_responses()
@ -593,6 +594,7 @@ class TestRoute53Provider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
self.assertEquals(9, len(plan.changes)) self.assertEquals(9, len(plan.changes))
self.assertFalse(plan.exists)
for change in plan.changes: for change in plan.changes:
self.assertIsInstance(change, Create) self.assertIsInstance(change, Create)
stubber.assert_no_pending_responses() stubber.assert_no_pending_responses()


+ 2
- 2
tests/test_octodns_record.py View File

@ -430,7 +430,7 @@ class TestRecord(TestCase):
self.assertEqual(change.new, other) self.assertEqual(change.new, other)
# full sorting # full sorting
# equivilent
# equivalent
b_naptr_value = b.values[0] b_naptr_value = b.values[0]
self.assertEquals(0, b_naptr_value.__cmp__(b_naptr_value)) self.assertEquals(0, b_naptr_value.__cmp__(b_naptr_value))
# by order # by order
@ -710,7 +710,7 @@ class TestRecord(TestCase):
Record.new(self.zone, 'unknown', {}) Record.new(self.zone, 'unknown', {})
self.assertTrue('missing type' in ctx.exception.message) self.assertTrue('missing type' in ctx.exception.message)
# Unkown type
# Unknown type
with self.assertRaises(Exception) as ctx: with self.assertRaises(Exception) as ctx:
Record.new(self.zone, 'unknown', { Record.new(self.zone, 'unknown', {
'type': 'XXX', 'type': 'XXX',


Loading…
Cancel
Save