Browse Source

Merge branch 'master' into sdist-tests

pull/609/head
Ross McFarland 5 years ago
committed by GitHub
parent
commit
c9ae0e7d49
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1428 additions and 131 deletions
  1. +6
    -0
      README.md
  2. +1
    -1
      docs/geo_records.md
  3. +40
    -1
      docs/records.md
  4. +1
    -2
      octodns/cmds/report.py
  5. +106
    -20
      octodns/manager.py
  6. +378
    -0
      octodns/provider/gandi.py
  7. +2
    -2
      octodns/provider/yaml.py
  8. +30
    -0
      octodns/record/__init__.py
  9. +2
    -2
      requirements.txt
  10. +1
    -1
      script/coverage
  11. +21
    -0
      tests/config/alias-zone-loop.yaml
  12. +19
    -0
      tests/config/simple-alias-zone.yaml
  13. +5
    -0
      tests/config/split/unit.tests./dname.yaml
  14. +4
    -0
      tests/config/unit.tests.yaml
  15. +18
    -0
      tests/config/unknown-source-zone.yaml
  16. +0
    -37
      tests/fixtures/constellix-records.json
  17. +0
    -14
      tests/fixtures/dnsmadeeasy-records.json
  18. +136
    -0
      tests/fixtures/gandi-no-changes.json
  19. +111
    -0
      tests/fixtures/gandi-records.json
  20. +7
    -0
      tests/fixtures/gandi-zone.json
  21. +61
    -7
      tests/test_octodns_manager.py
  22. +4
    -10
      tests/test_octodns_provider_constellix.py
  23. +1
    -1
      tests/test_octodns_provider_digitalocean.py
  24. +1
    -1
      tests/test_octodns_provider_dnsimple.py
  25. +4
    -10
      tests/test_octodns_provider_dnsmadeeasy.py
  26. +1
    -1
      tests/test_octodns_provider_easydns.py
  27. +361
    -0
      tests/test_octodns_provider_gandi.py
  28. +2
    -2
      tests/test_octodns_provider_mythicbeasts.py
  29. +2
    -2
      tests/test_octodns_provider_powerdns.py
  30. +12
    -11
      tests/test_octodns_provider_yaml.py
  31. +91
    -6
      tests/test_octodns_record.py

+ 6
- 0
README.md View File

@ -79,6 +79,9 @@ zones:
targets: targets:
- dyn - dyn
- route53 - route53
example.net:
alias: example.com.
``` ```
`class` is a special key that tells OctoDNS what python class should be loaded. Any other keys will be passed as configuration values to that provider. In general any sensitive or frequently rotated values should come from environmental variables. When OctoDNS sees a value that starts with `env/` it will look for that value in the process's environment and pass the result along. `class` is a special key that tells OctoDNS what python class should be loaded. Any other keys will be passed as configuration values to that provider. In general any sensitive or frequently rotated values should come from environmental variables. When OctoDNS sees a value that starts with `env/` it will look for that value in the process's environment and pass the result along.
@ -87,6 +90,8 @@ Further information can be found in the `docstring` of each source and provider
The `max_workers` key in the `manager` section of the config enables threading to parallelize the planning portion of the sync. The `max_workers` key in the `manager` section of the config enables threading to parallelize the planning portion of the sync.
In this example, `example.net` is an alias of zone `example.com`, which means they share the same sources and targets. They will therefore have identical records.
Now that we have something to tell OctoDNS about our providers & zones we need to tell it about or records. We'll keep it simple for now and just create a single `A` record at the top-level of the domain. Now that we have something to tell OctoDNS about our providers & zones we need to tell it about or records. We'll keep it simple for now and just create a single `A` record at the top-level of the domain.
`config/example.com.yaml` `config/example.com.yaml`
@ -189,6 +194,7 @@ The above command pulled the existing data out of Route53 and placed the results
| [EasyDNSProvider](/octodns/provider/easydns.py) | | A, AAAA, CAA, CNAME, MX, NAPTR, NS, SRV, TXT | No | | | [EasyDNSProvider](/octodns/provider/easydns.py) | | A, AAAA, CAA, CNAME, MX, NAPTR, NS, SRV, TXT | No | |
| [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | A, AAAA, ALIAS, CNAME | No | | | [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | A, AAAA, ALIAS, CNAME | No | |
| [EnvVarSource](/octodns/source/envvar.py) | | TXT | No | read-only environment variable injection | | [EnvVarSource](/octodns/source/envvar.py) | | TXT | No | read-only environment variable injection |
| [GandiProvider](/octodns/provider/gandi.py) | | A, AAAA, ALIAS, CAA, CNAME, DNAME, MX, NS, PTR, SPF, SRV, SSHFP, TXT | No | |
| [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | |
| [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | | | [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | |
| [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | Missing `NA` geo target | | [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | Missing `NA` geo target |


+ 1
- 1
docs/geo_records.md View File

@ -1,6 +1,6 @@
## Geo Record Support ## Geo Record Support
Note: Geo DNS records are still supported for the time being, but it is still strongy encouraged that you look at [Dynamic Records](/docs/dynamic_records.md) instead as they are a superset of functionality.
Note: Geo DNS records are still supported for the time being, but it is still strongly encouraged that you look at [Dynamic Records](/docs/dynamic_records.md) instead as they are a superset of functionality.
GeoDNS is currently supported for `A` and `AAAA` records on the Dyn (via Traffic Directors) and Route53 providers. Records with geo information pushed to providers without support for them will be managed as non-geo records using the base values. GeoDNS is currently supported for `A` and `AAAA` records on the Dyn (via Traffic Directors) and Route53 providers. Records with geo information pushed to providers without support for them will be managed as non-geo records using the base values.


+ 40
- 1
docs/records.md View File

@ -6,14 +6,17 @@ OctoDNS supports the following record types:
* `A` * `A`
* `AAAA` * `AAAA`
* `ALIAS`
* `CAA`
* `CNAME` * `CNAME`
* `DNAME`
* `MX` * `MX`
* `NAPTR` * `NAPTR`
* `NS` * `NS`
* `PTR` * `PTR`
* `SSHFP`
* `SPF` * `SPF`
* `SRV` * `SRV`
* `SSHFP`
* `TXT` * `TXT`
Underlying provider support for each of these varies and some providers have extra requirements or limitations. In cases where a record type is not supported by a provider OctoDNS will ignore it there and continue to manage the record elsewhere. For example `SSHFP` is supported by Dyn, but not Route53. If your source data includes an SSHFP record OctoDNS will keep it in sync on Dyn, but not consider it when evaluating the state of Route53. The best way to find out what types are supported by a provider is to look for its `supports` method. If that method exists the logic will drive which records are supported and which are ignored. If the provider does not implement the method it will fall back to `BaseProvider.supports` which indicates full support. Underlying provider support for each of these varies and some providers have extra requirements or limitations. In cases where a record type is not supported by a provider OctoDNS will ignore it there and continue to manage the record elsewhere. For example `SSHFP` is supported by Dyn, but not Route53. If your source data includes an SSHFP record OctoDNS will keep it in sync on Dyn, but not consider it when evaluating the state of Route53. The best way to find out what types are supported by a provider is to look for its `supports` method. If that method exists the logic will drive which records are supported and which are ignored. If the provider does not implement the method it will fall back to `BaseProvider.supports` which indicates full support.
@ -81,3 +84,39 @@ In the above example each name had a single record, but there are cases where a
Each record type has a corresponding set of required data. The easiest way to determine what's required is probably to look at the record object in [`octodns/record/__init__.py`](/octodns/record/__init__.py). You may also utilize `octodns-validate` which will throw errors about what's missing when run. Each record type has a corresponding set of required data. The easiest way to determine what's required is probably to look at the record object in [`octodns/record/__init__.py`](/octodns/record/__init__.py). You may also utilize `octodns-validate` which will throw errors about what's missing when run.
`type` is required for all records. `ttl` is optional. When TTL is not specified the `YamlProvider`'s default will be used. In any situation where an array of `values` can be used you can opt to go with `value` as a single item if there's only one. `type` is required for all records. `ttl` is optional. When TTL is not specified the `YamlProvider`'s default will be used. In any situation where an array of `values` can be used you can opt to go with `value` as a single item if there's only one.
### Lenience
octoDNS is fairly strict in terms of standards compliance and is opinionated in terms of best practices. Examples of former include SRV record naming requirements and the latter that ALIAS records are constrained to the root of zones. The strictness and support of providers varies so you may encounter existing records that fail validation when you try to dump them or you may even have use cases for which you need to create or preserve records that don't validate. octoDNS's solution to this is the `lenient` flag.
It's best to think of the `lenient` flag as "I know what I'm doing and accept any problems I run across." The main reason being is that some providers may allow the non-compliant setup and others may not. The behavior of the non-compliant records may even vary from one provider to another. Caveat emptor.
#### octodns-dump
If you're trying to import a zone into octoDNS config file using `octodns-dump` which fails due to validation errors you can supply the `--lenient` argument to tell octoDNS that you acknowledge that things aren't lining up with its expectations, but you'd like it to go ahead anyway. This will do its best to populate the zone and dump the results out into an octoDNS zone file and include the non-compliant bits. If you go to use that config file octoDNS will again complain about the validation problems. You can correct them in cases where that makes sense, but if you need to preserve the non-compliant records read on for options.
#### Record level lenience
When there are non-compliant records configured in Yaml you can add the following to tell octoDNS to do it's best to proceed with them anyway. If you use `--lenient` above to dump a zone and you'd like to sync it as-is you can mark the problematic records this way.
```yaml
'not-root':
octodns:
lenient: true
type: ALIAS
values: something.else.com.
```
#### Zone level lenience
If you'd like to enable lenience for a whole zone you can do so with the following, thought it's strongly encouraged to mark things at record level when possible. The most common case where things may need to be done at the zone level is when using something other than `YamlProvider` as a source, e.g. syncing from `Route53Provider` to `Ns1Provider` when there are non-compliant records in the zone in Route53.
```yaml
non-compliant-zone.com.:
octodns:
lenient: true
sources:
- route53
targets:
- ns1
```

+ 1
- 2
octodns/cmds/report.py View File

@ -17,7 +17,6 @@ from six import text_type
from octodns.cmds.args import ArgumentParser from octodns.cmds.args import ArgumentParser
from octodns.manager import Manager from octodns.manager import Manager
from octodns.zone import Zone
class AsyncResolver(Resolver): class AsyncResolver(Resolver):
@ -56,7 +55,7 @@ def main():
except KeyError as e: except KeyError as e:
raise Exception('Unknown source: {}'.format(e.args[0])) raise Exception('Unknown source: {}'.format(e.args[0]))
zone = Zone(args.zone, manager.configured_sub_zones(args.zone))
zone = manager.get_zone(args.zone)
for source in sources: for source in sources:
source.populate(zone) source.populate(zone)


+ 106
- 20
octodns/manager.py View File

@ -222,21 +222,31 @@ class Manager(object):
self.log.debug('configured_sub_zones: subs=%s', sub_zone_names) self.log.debug('configured_sub_zones: subs=%s', sub_zone_names)
return set(sub_zone_names) return set(sub_zone_names)
def _populate_and_plan(self, zone_name, sources, targets, lenient=False):
def _populate_and_plan(self, zone_name, sources, targets, desired=None,
lenient=False):
self.log.debug('sync: populating, zone=%s, lenient=%s', self.log.debug('sync: populating, zone=%s, lenient=%s',
zone_name, lenient) zone_name, lenient)
zone = Zone(zone_name, zone = Zone(zone_name,
sub_zones=self.configured_sub_zones(zone_name)) sub_zones=self.configured_sub_zones(zone_name))
for source in sources:
try:
source.populate(zone, lenient=lenient)
except TypeError as e:
if "keyword argument 'lenient'" not in text_type(e):
raise
self.log.warn(': provider %s does not accept lenient param',
source.__class__.__name__)
source.populate(zone)
if desired:
# This is an alias zone, rather than populate it we'll copy the
# records over from `desired`.
for _, records in desired._records.items():
for record in records:
zone.add_record(record.copy(zone=zone), lenient=lenient)
else:
for source in sources:
try:
source.populate(zone, lenient=lenient)
except TypeError as e:
if "keyword argument 'lenient'" not in text_type(e):
raise
self.log.warn(': provider %s does not accept lenient '
'param', source.__class__.__name__)
source.populate(zone)
self.log.debug('sync: planning, zone=%s', zone_name) self.log.debug('sync: planning, zone=%s', zone_name)
plans = [] plans = []
@ -253,7 +263,8 @@ class Manager(object):
if plan: if plan:
plans.append((target, plan)) plans.append((target, plan))
return plans
# Return the zone as it's the desired state
return plans, zone
def sync(self, eligible_zones=[], eligible_sources=[], eligible_targets=[], def sync(self, eligible_zones=[], eligible_sources=[], eligible_targets=[],
dry_run=True, force=False): dry_run=True, force=False):
@ -265,9 +276,32 @@ class Manager(object):
if eligible_zones: if eligible_zones:
zones = [z for z in zones if z[0] in eligible_zones] zones = [z for z in zones if z[0] in eligible_zones]
aliased_zones = {}
futures = [] futures = []
for zone_name, config in zones: for zone_name, config in zones:
self.log.info('sync: zone=%s', zone_name) self.log.info('sync: zone=%s', zone_name)
if 'alias' in config:
source_zone = config['alias']
# Check that the source zone is defined.
if source_zone not in self.config['zones']:
self.log.error('Invalid alias zone {}, target {} does '
'not exist'.format(zone_name, source_zone))
raise ManagerException('Invalid alias zone {}: '
'source zone {} does not exist'
.format(zone_name, source_zone))
# Check that the source zone is not an alias zone itself.
if 'alias' in self.config['zones'][source_zone]:
self.log.error('Invalid alias zone {}, target {} is an '
'alias zone'.format(zone_name, source_zone))
raise ManagerException('Invalid alias zone {}: source '
'zone {} is an alias zone'
.format(zone_name, source_zone))
aliased_zones[zone_name] = source_zone
continue
lenient = config.get('lenient', False) lenient = config.get('lenient', False)
try: try:
sources = config['sources'] sources = config['sources']
@ -327,9 +361,32 @@ class Manager(object):
zone_name, sources, zone_name, sources,
targets, lenient=lenient)) targets, lenient=lenient))
# Wait on all results and unpack/flatten them in to a list of target &
# plan pairs.
plans = [p for f in futures for p in f.result()]
# Wait on all results and unpack/flatten the plans and store the
# desired states in case we need them below
plans = []
desired = {}
for future in futures:
ps, d = future.result()
desired[d.name] = d
for plan in ps:
plans.append(plan)
# Populate aliases zones.
futures = []
for zone_name, zone_source in aliased_zones.items():
source_config = self.config['zones'][zone_source]
futures.append(self._executor.submit(
self._populate_and_plan,
zone_name,
[],
[self.providers[t] for t in source_config['targets']],
desired=desired[zone_source],
lenient=lenient
))
# Wait on results and unpack/flatten the plans, ignore the desired here
# as these are aliased zones
plans += [p for f in futures for p in f.result()[0]]
# Best effort sort plans children first so that we create/update # Best effort sort plans children first so that we create/update
# children zones before parents which should allow us to more safely # children zones before parents which should allow us to more safely
@ -377,12 +434,11 @@ class Manager(object):
except KeyError as e: except KeyError as e:
raise ManagerException('Unknown source: {}'.format(e.args[0])) raise ManagerException('Unknown source: {}'.format(e.args[0]))
sub_zones = self.configured_sub_zones(zone)
za = Zone(zone, sub_zones)
za = self.get_zone(zone)
for source in a: for source in a:
source.populate(za) source.populate(za)
zb = Zone(zone, sub_zones)
zb = self.get_zone(zone)
for source in b: for source in b:
source.populate(zb) source.populate(zb)
@ -421,6 +477,25 @@ class Manager(object):
for zone_name, config in self.config['zones'].items(): for zone_name, config in self.config['zones'].items():
zone = Zone(zone_name, self.configured_sub_zones(zone_name)) zone = Zone(zone_name, self.configured_sub_zones(zone_name))
source_zone = config.get('alias')
if source_zone:
if source_zone not in self.config['zones']:
self.log.exception('Invalid alias zone')
raise ManagerException('Invalid alias zone {}: '
'source zone {} does not exist'
.format(zone_name, source_zone))
if 'alias' in self.config['zones'][source_zone]:
self.log.exception('Invalid alias zone')
raise ManagerException('Invalid alias zone {}: '
'source zone {} is an alias zone'
.format(zone_name, source_zone))
# this is just here to satisfy coverage, see
# https://github.com/nedbat/coveragepy/issues/198
source_zone = source_zone
continue
try: try:
sources = config['sources'] sources = config['sources']
except KeyError: except KeyError:
@ -428,9 +503,9 @@ class Manager(object):
.format(zone_name)) .format(zone_name))
try: try:
# rather than using a list comprehension, we break this loop
# out so that the `except` block below can reference the
# `source`
# rather than using a list comprehension, we break this
# loop out so that the `except` block below can reference
# the `source`
collected = [] collected = []
for source in sources: for source in sources:
collected.append(self.providers[source]) collected.append(self.providers[source])
@ -442,3 +517,14 @@ class Manager(object):
for source in sources: for source in sources:
if isinstance(source, YamlProvider): if isinstance(source, YamlProvider):
source.populate(zone) source.populate(zone)
def get_zone(self, zone_name):
if not zone_name[-1] == '.':
raise ManagerException('Invalid zone name {}, missing ending dot'
.format(zone_name))
for name, config in self.config['zones'].items():
if name == zone_name:
return Zone(name, self.configured_sub_zones(name))
raise ManagerException('Unknown zone name {}'.format(zone_name))

+ 378
- 0
octodns/provider/gandi.py View File

@ -0,0 +1,378 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from collections import defaultdict
from requests import Session
import logging
from ..record import Record
from .base import BaseProvider
class GandiClientException(Exception):
pass
class GandiClientBadRequest(GandiClientException):
def __init__(self, r):
super(GandiClientBadRequest, self).__init__(r.text)
class GandiClientUnauthorized(GandiClientException):
def __init__(self, r):
super(GandiClientUnauthorized, self).__init__(r.text)
class GandiClientForbidden(GandiClientException):
def __init__(self, r):
super(GandiClientForbidden, self).__init__(r.text)
class GandiClientNotFound(GandiClientException):
def __init__(self, r):
super(GandiClientNotFound, self).__init__(r.text)
class GandiClientUnknownDomainName(GandiClientException):
def __init__(self, msg):
super(GandiClientUnknownDomainName, self).__init__(msg)
class GandiClient(object):
def __init__(self, token):
session = Session()
session.headers.update({'Authorization': 'Apikey {}'.format(token)})
self._session = session
self.endpoint = 'https://api.gandi.net/v5'
def _request(self, method, path, params={}, data=None):
url = '{}{}'.format(self.endpoint, path)
r = self._session.request(method, url, params=params, json=data)
if r.status_code == 400:
raise GandiClientBadRequest(r)
if r.status_code == 401:
raise GandiClientUnauthorized(r)
elif r.status_code == 403:
raise GandiClientForbidden(r)
elif r.status_code == 404:
raise GandiClientNotFound(r)
r.raise_for_status()
return r
def zone(self, zone_name):
return self._request('GET', '/livedns/domains/{}'
.format(zone_name)).json()
def zone_create(self, zone_name):
return self._request('POST', '/livedns/domains', data={
'fqdn': zone_name,
'zone': {}
}).json()
def zone_records(self, zone_name):
records = self._request('GET', '/livedns/domains/{}/records'
.format(zone_name)).json()
for record in records:
if record['rrset_name'] == '@':
record['rrset_name'] = ''
# Change relative targets to absolute ones.
if record['rrset_type'] in ['ALIAS', 'CNAME', 'DNAME', 'MX',
'NS', 'SRV']:
for i, value in enumerate(record['rrset_values']):
if not value.endswith('.'):
record['rrset_values'][i] = '{}.{}.'.format(
value, zone_name)
return records
def record_create(self, zone_name, data):
self._request('POST', '/livedns/domains/{}/records'.format(zone_name),
data=data)
def record_delete(self, zone_name, record_name, record_type):
self._request('DELETE', '/livedns/domains/{}/records/{}/{}'
.format(zone_name, record_name, record_type))
class GandiProvider(BaseProvider):
'''
Gandi provider using API v5.
gandi:
class: octodns.provider.gandi.GandiProvider
# Your API key (required)
token: XXXXXXXXXXXX
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set((['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME',
'MX', 'NS', 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT']))
def __init__(self, id, token, *args, **kwargs):
self.log = logging.getLogger('GandiProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, token=***', id)
super(GandiProvider, self).__init__(id, *args, **kwargs)
self._client = GandiClient(token)
self._zone_records = {}
def _data_for_multiple(self, _type, records):
return {
'ttl': records[0]['rrset_ttl'],
'type': _type,
'values': [v.replace(';', '\\;') for v in
records[0]['rrset_values']] if _type == 'TXT' else
records[0]['rrset_values']
}
_data_for_A = _data_for_multiple
_data_for_AAAA = _data_for_multiple
_data_for_TXT = _data_for_multiple
_data_for_SPF = _data_for_multiple
_data_for_NS = _data_for_multiple
def _data_for_CAA(self, _type, records):
values = []
for record in records[0]['rrset_values']:
flags, tag, value = record.split(' ')
values.append({
'flags': flags,
'tag': tag,
# Remove quotes around value.
'value': value[1:-1],
})
return {
'ttl': records[0]['rrset_ttl'],
'type': _type,
'values': values
}
def _data_for_single(self, _type, records):
return {
'ttl': records[0]['rrset_ttl'],
'type': _type,
'value': records[0]['rrset_values'][0]
}
_data_for_ALIAS = _data_for_single
_data_for_CNAME = _data_for_single
_data_for_DNAME = _data_for_single
_data_for_PTR = _data_for_single
def _data_for_MX(self, _type, records):
values = []
for record in records[0]['rrset_values']:
priority, server = record.split(' ')
values.append({
'preference': priority,
'exchange': server
})
return {
'ttl': records[0]['rrset_ttl'],
'type': _type,
'values': values
}
def _data_for_SRV(self, _type, records):
values = []
for record in records[0]['rrset_values']:
priority, weight, port, target = record.split(' ', 3)
values.append({
'priority': priority,
'weight': weight,
'port': port,
'target': target
})
return {
'ttl': records[0]['rrset_ttl'],
'type': _type,
'values': values
}
def _data_for_SSHFP(self, _type, records):
values = []
for record in records[0]['rrset_values']:
algorithm, fingerprint_type, fingerprint = record.split(' ', 2)
values.append({
'algorithm': algorithm,
'fingerprint': fingerprint,
'fingerprint_type': fingerprint_type
})
return {
'ttl': records[0]['rrset_ttl'],
'type': _type,
'values': values
}
def zone_records(self, zone):
if zone.name not in self._zone_records:
try:
self._zone_records[zone.name] = \
self._client.zone_records(zone.name[:-1])
except GandiClientNotFound:
return []
return self._zone_records[zone.name]
def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient)
values = defaultdict(lambda: defaultdict(list))
for record in self.zone_records(zone):
_type = record['rrset_type']
if _type not in self.SUPPORTS:
continue
values[record['rrset_name']][record['rrset_type']].append(record)
before = len(zone.records)
for name, types in values.items():
for _type, records in types.items():
data_for = getattr(self, '_data_for_{}'.format(_type))
record = Record.new(zone, name, data_for(_type, records),
source=self, lenient=lenient)
zone.add_record(record, lenient=lenient)
exists = zone.name in self._zone_records
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _record_name(self, name):
return name if name else '@'
def _params_for_multiple(self, record):
return {
'rrset_name': self._record_name(record.name),
'rrset_ttl': record.ttl,
'rrset_type': record._type,
'rrset_values': [v.replace('\\;', ';') for v in
record.values] if record._type == 'TXT'
else record.values
}
_params_for_A = _params_for_multiple
_params_for_AAAA = _params_for_multiple
_params_for_NS = _params_for_multiple
_params_for_TXT = _params_for_multiple
_params_for_SPF = _params_for_multiple
def _params_for_CAA(self, record):
return {
'rrset_name': self._record_name(record.name),
'rrset_ttl': record.ttl,
'rrset_type': record._type,
'rrset_values': ['{} {} "{}"'.format(v.flags, v.tag, v.value)
for v in record.values]
}
def _params_for_single(self, record):
return {
'rrset_name': self._record_name(record.name),
'rrset_ttl': record.ttl,
'rrset_type': record._type,
'rrset_values': [record.value]
}
_params_for_ALIAS = _params_for_single
_params_for_CNAME = _params_for_single
_params_for_DNAME = _params_for_single
_params_for_PTR = _params_for_single
def _params_for_MX(self, record):
return {
'rrset_name': self._record_name(record.name),
'rrset_ttl': record.ttl,
'rrset_type': record._type,
'rrset_values': ['{} {}'.format(v.preference, v.exchange)
for v in record.values]
}
def _params_for_SRV(self, record):
return {
'rrset_name': self._record_name(record.name),
'rrset_ttl': record.ttl,
'rrset_type': record._type,
'rrset_values': ['{} {} {} {}'.format(v.priority, v.weight, v.port,
v.target) for v in record.values]
}
def _params_for_SSHFP(self, record):
return {
'rrset_name': self._record_name(record.name),
'rrset_ttl': record.ttl,
'rrset_type': record._type,
'rrset_values': ['{} {} {}'.format(v.algorithm, v.fingerprint_type,
v.fingerprint) for v in record.values]
}
def _apply_create(self, change):
new = change.new
data = getattr(self, '_params_for_{}'.format(new._type))(new)
self._client.record_create(new.zone.name[:-1], data)
def _apply_update(self, change):
self._apply_delete(change)
self._apply_create(change)
def _apply_delete(self, change):
existing = change.existing
zone = existing.zone
self._client.record_delete(zone.name[:-1],
self._record_name(existing.name),
existing._type)
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
zone = desired.name[:-1]
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
len(changes))
try:
self._client.zone(zone)
except GandiClientNotFound:
self.log.info('_apply: no existing zone, trying to create it')
try:
self._client.zone_create(zone)
self.log.info('_apply: zone has been successfully created')
except GandiClientNotFound:
# We suppress existing exception before raising
# GandiClientUnknownDomainName.
e = GandiClientUnknownDomainName('This domain is not '
'registred at Gandi. '
'Please register or '
'transfer it here '
'to be able to manage its '
'DNS zone.')
e.__cause__ = None
raise e
# Force records deletion to be done before creation in order to avoid
# "CNAME record must be the only record" error when an existing CNAME
# record is replaced by an A/AAAA record.
changes.reverse()
for change in changes:
class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name.lower()))(change)
# Clear out the cache if any
self._zone_records.pop(desired.name, None)

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

@ -104,8 +104,8 @@ class YamlProvider(BaseProvider):
''' '''
SUPPORTS_GEO = True SUPPORTS_GEO = True
SUPPORTS_DYNAMIC = True SUPPORTS_DYNAMIC = True
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS',
'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT'))
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', 'MX',
'NAPTR', 'NS', 'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT'))
def __init__(self, id, directory, default_ttl=3600, enforce_order=True, def __init__(self, id, directory, default_ttl=3600, enforce_order=True,
populate_should_replace=False, *args, **kwargs): populate_should_replace=False, *args, **kwargs):


+ 30
- 0
octodns/record/__init__.py View File

@ -95,6 +95,7 @@ class Record(EqualityTupleMixin):
'ALIAS': AliasRecord, 'ALIAS': AliasRecord,
'CAA': CaaRecord, 'CAA': CaaRecord,
'CNAME': CnameRecord, 'CNAME': CnameRecord,
'DNAME': DnameRecord,
'MX': MxRecord, 'MX': MxRecord,
'NAPTR': NaptrRecord, 'NAPTR': NaptrRecord,
'NS': NsRecord, 'NS': NsRecord,
@ -218,6 +219,18 @@ class Record(EqualityTupleMixin):
if self.ttl != other.ttl: if self.ttl != other.ttl:
return Update(self, other) return Update(self, other)
def copy(self, zone=None):
data = self.data
data['type'] = self._type
return Record.new(
zone if zone else self.zone,
self.name,
data,
self.source,
lenient=True
)
# NOTE: we're using __hash__ and ordering methods that consider Records # NOTE: we're using __hash__ and ordering 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 useful when computing diffs/changes. # is useful when computing diffs/changes.
@ -759,6 +772,10 @@ class CnameValue(_TargetValue):
pass pass
class DnameValue(_TargetValue):
pass
class ARecord(_DynamicMixin, _GeoMixin, Record): class ARecord(_DynamicMixin, _GeoMixin, Record):
_type = 'A' _type = 'A'
_value_type = Ipv4List _value_type = Ipv4List
@ -777,6 +794,14 @@ class AliasRecord(_ValueMixin, Record):
_type = 'ALIAS' _type = 'ALIAS'
_value_type = AliasValue _value_type = AliasValue
@classmethod
def validate(cls, name, fqdn, data):
reasons = []
if name != '':
reasons.append('non-root ALIAS not allowed')
reasons.extend(super(AliasRecord, cls).validate(name, fqdn, data))
return reasons
class CaaValue(EqualityTupleMixin): class CaaValue(EqualityTupleMixin):
# https://tools.ietf.org/html/rfc6844#page-5 # https://tools.ietf.org/html/rfc6844#page-5
@ -842,6 +867,11 @@ class CnameRecord(_DynamicMixin, _ValueMixin, Record):
return reasons return reasons
class DnameRecord(_DynamicMixin, _ValueMixin, Record):
_type = 'DNAME'
_value_type = DnameValue
class MxValue(EqualityTupleMixin): class MxValue(EqualityTupleMixin):
@classmethod @classmethod


+ 2
- 2
requirements.txt View File

@ -1,8 +1,8 @@
PyYaml==5.3.1 PyYaml==5.3.1
azure-common==1.1.25 azure-common==1.1.25
azure-mgmt-dns==3.0.0 azure-mgmt-dns==3.0.0
boto3==1.14.52
botocore==1.17.52
boto3==1.15.9
botocore==1.18.9
dnspython==1.16.0 dnspython==1.16.0
docutils==0.16 docutils==0.16
dyn==1.8.1 dyn==1.8.1


+ 1
- 1
script/coverage View File

@ -27,7 +27,7 @@ export DYN_USERNAME=
export GOOGLE_APPLICATION_CREDENTIALS= export GOOGLE_APPLICATION_CREDENTIALS=
# Don't allow disabling coverage # Don't allow disabling coverage
grep -r -I --line-number "# pragma: nocover" octodns && {
grep -r -I --line-number "# pragma: +no.*cover" octodns && {
echo "Code coverage should not be disabled" echo "Code coverage should not be disabled"
exit 1 exit 1
} }


+ 21
- 0
tests/config/alias-zone-loop.yaml View File

@ -0,0 +1,21 @@
manager:
max_workers: 2
providers:
in:
class: octodns.provider.yaml.YamlProvider
directory: tests/config
dump:
class: octodns.provider.yaml.YamlProvider
directory: env/YAML_TMP_DIR
zones:
unit.tests.:
sources:
- in
targets:
- dump
alias.tests.:
alias: unit.tests.
alias-loop.tests.:
alias: alias.tests.

+ 19
- 0
tests/config/simple-alias-zone.yaml View File

@ -0,0 +1,19 @@
manager:
max_workers: 2
providers:
in:
class: octodns.provider.yaml.YamlProvider
directory: tests/config
dump:
class: octodns.provider.yaml.YamlProvider
directory: env/YAML_TMP_DIR
zones:
unit.tests.:
sources:
- in
targets:
- dump
alias.tests.:
alias: unit.tests.

+ 5
- 0
tests/config/split/unit.tests./dname.yaml View File

@ -0,0 +1,5 @@
---
dname:
ttl: 300
type: DNAME
value: unit.tests.

+ 4
- 0
tests/config/unit.tests.yaml View File

@ -56,6 +56,10 @@ cname:
ttl: 300 ttl: 300
type: CNAME type: CNAME
value: unit.tests. value: unit.tests.
dname:
ttl: 300
type: DNAME
value: unit.tests.
excluded: excluded:
octodns: octodns:
excluded: excluded:


+ 18
- 0
tests/config/unknown-source-zone.yaml View File

@ -0,0 +1,18 @@
manager:
max_workers: 2
providers:
in:
class: octodns.provider.yaml.YamlProvider
directory: tests/config
dump:
class: octodns.provider.yaml.YamlProvider
directory: env/YAML_TMP_DIR
zones:
unit.tests.:
sources:
- in
targets:
- dump
alias.tests.:
alias: does-not-exists.tests.

+ 0
- 37
tests/fixtures/constellix-records.json View File

@ -523,43 +523,6 @@
"roundRobinFailover": [], "roundRobinFailover": [],
"pools": [], "pools": [],
"poolsDetail": [] "poolsDetail": []
}, {
"id": 1808603,
"type": "ANAME",
"recordType": "aname",
"name": "sub",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 1800,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565153387855,
"value": [{
"value": "aname.unit.tests.",
"disableFlag": false
}],
"roundRobin": [{
"value": "aname.unit.tests.",
"disableFlag": false
}],
"geolocation": null,
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"pools": [],
"poolsDetail": []
}, { }, {
"id": 1808520, "id": 1808520,
"type": "A", "type": "A",


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

@ -320,20 +320,6 @@
"name": "", "name": "",
"value": "aname.unit.tests.", "value": "aname.unit.tests.",
"id": 11189895, "id": 11189895,
"type": "ANAME"
}, {
"failover": false,
"monitor": false,
"sourceId": 123123,
"dynamicDns": false,
"failed": false,
"gtdLocation": "DEFAULT",
"hardLink": false,
"ttl": 1800,
"source": 1,
"name": "sub",
"value": "aname",
"id": 11189896,
"type": "ANAME" "type": "ANAME"
}, { }, {
"failover": false, "failover": false,


+ 136
- 0
tests/fixtures/gandi-no-changes.json View File

@ -0,0 +1,136 @@
[
{
"rrset_type": "A",
"rrset_ttl": 300,
"rrset_name": "@",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A",
"rrset_values": [
"1.2.3.4",
"1.2.3.5"
]
},
{
"rrset_type": "CAA",
"rrset_ttl": 3600,
"rrset_name": "@",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/CAA",
"rrset_values": [
"0 issue \"ca.unit.tests\""
]
},
{
"rrset_type": "SSHFP",
"rrset_ttl": 3600,
"rrset_name": "@",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/SSHFP",
"rrset_values": [
"1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49",
"1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73"
]
},
{
"rrset_type": "AAAA",
"rrset_ttl": 600,
"rrset_name": "aaaa",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/aaaa/AAAA",
"rrset_values": [
"2601:644:500:e210:62f8:1dff:feb8:947a"
]
},
{
"rrset_type": "CNAME",
"rrset_ttl": 300,
"rrset_name": "cname",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/cname/CNAME",
"rrset_values": [
"unit.tests."
]
},
{
"rrset_type": "DNAME",
"rrset_ttl": 300,
"rrset_name": "dname",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/dname/DNAME",
"rrset_values": [
"unit.tests."
]
},
{
"rrset_type": "CNAME",
"rrset_ttl": 3600,
"rrset_name": "excluded",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/excluded/CNAME",
"rrset_values": [
"unit.tests."
]
},
{
"rrset_type": "MX",
"rrset_ttl": 300,
"rrset_name": "mx",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/mx/MX",
"rrset_values": [
"10 smtp-4.unit.tests.",
"20 smtp-2.unit.tests.",
"30 smtp-3.unit.tests.",
"40 smtp-1.unit.tests."
]
},
{
"rrset_type": "PTR",
"rrset_ttl": 300,
"rrset_name": "ptr",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/ptr/PTR",
"rrset_values": [
"foo.bar.com."
]
},
{
"rrset_type": "SPF",
"rrset_ttl": 600,
"rrset_name": "spf",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/spf/SPF",
"rrset_values": [
"\"v=spf1 ip4:192.168.0.1/16-all\""
]
},
{
"rrset_type": "TXT",
"rrset_ttl": 600,
"rrset_name": "txt",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/txt/TXT",
"rrset_values": [
"\"Bah bah black sheep\"",
"\"have you any wool.\"",
"\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\""
]
},
{
"rrset_type": "A",
"rrset_ttl": 300,
"rrset_name": "www",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/A",
"rrset_values": [
"2.2.3.6"
]
},
{
"rrset_type": "A",
"rrset_ttl": 300,
"rrset_name": "www.sub",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www.sub/A",
"rrset_values": [
"2.2.3.6"
]
},
{
"rrset_type": "SRV",
"rrset_ttl": 600,
"rrset_name": "_srv._tcp",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_srv._tcp/SRV",
"rrset_values": [
"10 20 30 foo-1.unit.tests.",
"12 20 30 foo-2.unit.tests."
]
}
]

+ 111
- 0
tests/fixtures/gandi-records.json View File

@ -0,0 +1,111 @@
[
{
"rrset_type": "A",
"rrset_ttl": 10800,
"rrset_name": "@",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A",
"rrset_values": [
"217.70.184.38"
]
},
{
"rrset_type": "MX",
"rrset_ttl": 10800,
"rrset_name": "@",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/MX",
"rrset_values": [
"10 spool.mail.gandi.net.",
"50 fb.mail.gandi.net."
]
},
{
"rrset_type": "TXT",
"rrset_ttl": 10800,
"rrset_name": "@",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/TXT",
"rrset_values": [
"\"v=spf1 include:_mailcust.gandi.net ?all\""
]
},
{
"rrset_type": "CNAME",
"rrset_ttl": 10800,
"rrset_name": "webmail",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/webmail/CNAME",
"rrset_values": [
"webmail.gandi.net."
]
},
{
"rrset_type": "CNAME",
"rrset_ttl": 10800,
"rrset_name": "www",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/CNAME",
"rrset_values": [
"webredir.vip.gandi.net."
]
},
{
"rrset_type": "SRV",
"rrset_ttl": 10800,
"rrset_name": "_imap._tcp",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV",
"rrset_values": [
"0 0 0 ."
]
},
{
"rrset_type": "SRV",
"rrset_ttl": 10800,
"rrset_name": "_imaps._tcp",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imaps._tcp/SRV",
"rrset_values": [
"0 1 993 mail.gandi.net."
]
},
{
"rrset_type": "SRV",
"rrset_ttl": 10800,
"rrset_name": "_pop3._tcp",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV",
"rrset_values": [
"0 0 0 ."
]
},
{
"rrset_type": "SRV",
"rrset_ttl": 10800,
"rrset_name": "_pop3s._tcp",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3s._tcp/SRV",
"rrset_values": [
"10 1 995 mail.gandi.net."
]
},
{
"rrset_type": "SRV",
"rrset_ttl": 10800,
"rrset_name": "_submission._tcp",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_submission._tcp/SRV",
"rrset_values": [
"0 1 465 mail.gandi.net."
]
},
{
"rrset_type": "CDS",
"rrset_ttl": 10800,
"rrset_name": "sub",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/sub/CDS",
"rrset_values": [
"32128 13 1 6823D9BB1B03DF714DD0EB163E20B341C96D18C0"
]
},
{
"rrset_type": "CNAME",
"rrset_ttl": 10800,
"rrset_name": "relative",
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/relative/CNAME",
"rrset_values": [
"target"
]
}
]

+ 7
- 0
tests/fixtures/gandi-zone.json View File

@ -0,0 +1,7 @@
{
"domain_keys_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/keys",
"fqdn": "unit.tests",
"automatic_snapshots": true,
"domain_records_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records",
"domain_href": "https://api.gandi.net/v5/livedns/domains/unit.tests"
}

+ 61
- 7
tests/test_octodns_manager.py View File

@ -118,12 +118,12 @@ class TestManager(TestCase):
environ['YAML_TMP_DIR'] = tmpdir.dirname environ['YAML_TMP_DIR'] = tmpdir.dirname
tc = Manager(get_config_filename('simple.yaml')) \ tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False) .sync(dry_run=False)
self.assertEquals(21, tc)
self.assertEquals(22, tc)
# try with just one of the zones # try with just one of the zones
tc = Manager(get_config_filename('simple.yaml')) \ tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False, eligible_zones=['unit.tests.']) .sync(dry_run=False, eligible_zones=['unit.tests.'])
self.assertEquals(15, tc)
self.assertEquals(16, tc)
# the subzone, with 2 targets # the subzone, with 2 targets
tc = Manager(get_config_filename('simple.yaml')) \ tc = Manager(get_config_filename('simple.yaml')) \
@ -138,18 +138,18 @@ class TestManager(TestCase):
# Again with force # Again with force
tc = Manager(get_config_filename('simple.yaml')) \ tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False, force=True) .sync(dry_run=False, force=True)
self.assertEquals(21, tc)
self.assertEquals(22, tc)
# Again with max_workers = 1 # Again with max_workers = 1
tc = Manager(get_config_filename('simple.yaml'), max_workers=1) \ tc = Manager(get_config_filename('simple.yaml'), max_workers=1) \
.sync(dry_run=False, force=True) .sync(dry_run=False, force=True)
self.assertEquals(21, tc)
self.assertEquals(22, tc)
# Include meta # Include meta
tc = Manager(get_config_filename('simple.yaml'), max_workers=1, tc = Manager(get_config_filename('simple.yaml'), max_workers=1,
include_meta=True) \ include_meta=True) \
.sync(dry_run=False, force=True) .sync(dry_run=False, force=True)
self.assertEquals(25, tc)
self.assertEquals(26, tc)
def test_eligible_sources(self): def test_eligible_sources(self):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
@ -167,6 +167,30 @@ class TestManager(TestCase):
.sync(eligible_targets=['foo']) .sync(eligible_targets=['foo'])
self.assertEquals(0, tc) self.assertEquals(0, tc)
def test_aliases(self):
with TemporaryDirectory() as tmpdir:
environ['YAML_TMP_DIR'] = tmpdir.dirname
# Alias zones with a valid target.
tc = Manager(get_config_filename('simple-alias-zone.yaml')) \
.sync()
self.assertEquals(0, tc)
# Alias zone with an invalid target.
with self.assertRaises(ManagerException) as ctx:
tc = Manager(get_config_filename('unknown-source-zone.yaml')) \
.sync()
self.assertEquals('Invalid alias zone alias.tests.: source zone '
'does-not-exists.tests. does not exist',
text_type(ctx.exception))
# Alias zone that points to another alias zone.
with self.assertRaises(ManagerException) as ctx:
tc = Manager(get_config_filename('alias-zone-loop.yaml')) \
.sync()
self.assertEquals('Invalid alias zone alias-loop.tests.: source '
'zone alias.tests. is an alias zone',
text_type(ctx.exception))
def test_compare(self): def test_compare(self):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
environ['YAML_TMP_DIR'] = tmpdir.dirname environ['YAML_TMP_DIR'] = tmpdir.dirname
@ -183,13 +207,13 @@ class TestManager(TestCase):
fh.write('---\n{}') fh.write('---\n{}')
changes = manager.compare(['in'], ['dump'], 'unit.tests.') changes = manager.compare(['in'], ['dump'], 'unit.tests.')
self.assertEquals(15, len(changes))
self.assertEquals(16, len(changes))
# Compound sources with varying support # Compound sources with varying support
changes = manager.compare(['in', 'nosshfp'], changes = manager.compare(['in', 'nosshfp'],
['dump'], ['dump'],
'unit.tests.') 'unit.tests.')
self.assertEquals(14, len(changes))
self.assertEquals(15, len(changes))
with self.assertRaises(ManagerException) as ctx: with self.assertRaises(ManagerException) as ctx:
manager.compare(['nope'], ['dump'], 'unit.tests.') manager.compare(['nope'], ['dump'], 'unit.tests.')
@ -286,6 +310,36 @@ class TestManager(TestCase):
.validate_configs() .validate_configs()
self.assertTrue('unknown source' in text_type(ctx.exception)) self.assertTrue('unknown source' in text_type(ctx.exception))
# Alias zone using an invalid source zone.
with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('unknown-source-zone.yaml')) \
.validate_configs()
self.assertTrue('does not exist' in
text_type(ctx.exception))
# Alias zone that points to another alias zone.
with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('alias-zone-loop.yaml')) \
.validate_configs()
self.assertTrue('is an alias zone' in
text_type(ctx.exception))
# Valid config file using an alias zone.
Manager(get_config_filename('simple-alias-zone.yaml')) \
.validate_configs()
def test_get_zone(self):
Manager(get_config_filename('simple.yaml')).get_zone('unit.tests.')
with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('simple.yaml')).get_zone('unit.tests')
self.assertTrue('missing ending dot' in text_type(ctx.exception))
with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('simple.yaml')) \
.get_zone('unknown-zone.tests.')
self.assertTrue('Unknown zone name' in text_type(ctx.exception))
def test_populate_lenient_fallback(self): def test_populate_lenient_fallback(self):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
environ['YAML_TMP_DIR'] = tmpdir.dirname environ['YAML_TMP_DIR'] = tmpdir.dirname


+ 4
- 10
tests/test_octodns_provider_constellix.py View File

@ -42,12 +42,6 @@ class TestConstellixProvider(TestCase):
'value': 'aname.unit.tests.' 'value': 'aname.unit.tests.'
})) }))
expected.add_record(Record.new(expected, 'sub', {
'ttl': 1800,
'type': 'ALIAS',
'value': 'aname.unit.tests.'
}))
for record in list(expected.records): for record in list(expected.records):
if record.name == 'sub' and record._type == 'NS': if record.name == 'sub' and record._type == 'NS':
expected._remove_record(record) expected._remove_record(record)
@ -107,14 +101,14 @@ class TestConstellixProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(15, len(zone.records))
self.assertEquals(14, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache # 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(15, len(again.records))
self.assertEquals(14, len(again.records))
# bust the cache # bust the cache
del provider._zone_records[zone.name] del provider._zone_records[zone.name]
@ -138,7 +132,7 @@ class TestConstellixProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported # No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 5
n = len(self.expected.records) - 6
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
@ -169,7 +163,7 @@ class TestConstellixProvider(TestCase):
}), }),
]) ])
self.assertEquals(18, provider._client._request.call_count)
self.assertEquals(17, provider._client._request.call_count)
provider._client._request.reset_mock() provider._client._request.reset_mock()


+ 1
- 1
tests/test_octodns_provider_digitalocean.py View File

@ -163,7 +163,7 @@ class TestDigitalOceanProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported # No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 7
n = len(self.expected.records) - 8
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) self.assertFalse(plan.exists)


+ 1
- 1
tests/test_octodns_provider_dnsimple.py View File

@ -137,7 +137,7 @@ class TestDnsimpleProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded # No root NS, no ignored, no excluded
n = len(self.expected.records) - 3
n = len(self.expected.records) - 4
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) self.assertFalse(plan.exists)


+ 4
- 10
tests/test_octodns_provider_dnsmadeeasy.py View File

@ -44,12 +44,6 @@ class TestDnsMadeEasyProvider(TestCase):
'value': 'aname.unit.tests.' 'value': 'aname.unit.tests.'
})) }))
expected.add_record(Record.new(expected, 'sub', {
'ttl': 1800,
'type': 'ALIAS',
'value': 'aname.unit.tests.'
}))
for record in list(expected.records): for record in list(expected.records):
if record.name == 'sub' and record._type == 'NS': if record.name == 'sub' and record._type == 'NS':
expected._remove_record(record) expected._remove_record(record)
@ -108,14 +102,14 @@ class TestDnsMadeEasyProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(15, len(zone.records))
self.assertEquals(14, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache # 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(15, len(again.records))
self.assertEquals(14, len(again.records))
# bust the cache # bust the cache
del provider._zone_records[zone.name] del provider._zone_records[zone.name]
@ -140,7 +134,7 @@ class TestDnsMadeEasyProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported # No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 5
n = len(self.expected.records) - 6
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
@ -180,7 +174,7 @@ class TestDnsMadeEasyProvider(TestCase):
'port': 30 'port': 30
}), }),
]) ])
self.assertEquals(27, provider._client._request.call_count)
self.assertEquals(26, provider._client._request.call_count)
provider._client._request.reset_mock() provider._client._request.reset_mock()


+ 1
- 1
tests/test_octodns_provider_easydns.py View File

@ -374,7 +374,7 @@ class TestEasyDNSProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported # No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 6
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) self.assertFalse(plan.exists)


+ 361
- 0
tests/test_octodns_provider_gandi.py View File

@ -0,0 +1,361 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from mock import Mock, call
from os.path import dirname, join
from requests import HTTPError
from requests_mock import ANY, mock as requests_mock
from six import text_type
from unittest import TestCase
from octodns.record import Record
from octodns.provider.gandi import GandiProvider, GandiClientBadRequest, \
GandiClientUnauthorized, GandiClientForbidden, GandiClientNotFound, \
GandiClientUnknownDomainName
from octodns.provider.yaml import YamlProvider
from octodns.zone import Zone
class TestGandiProvider(TestCase):
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
# We remove this record from the test zone as Gandi API reject it
# (rightfully).
expected._remove_record(Record.new(expected, 'sub', {
'ttl': 1800,
'type': 'NS',
'values': [
'6.2.3.4.',
'7.2.3.4.'
]
}))
def test_populate(self):
provider = GandiProvider('test_id', 'token')
# 400 - Bad Request.
with requests_mock() as mock:
mock.get(ANY, status_code=400,
text='{"status": "error", "errors": [{"location": '
'"body", "name": "items", "description": '
'"\'6.2.3.4.\': invalid hostname (param: '
'{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, '
'\'rrset_name\': u\'sub\', \'rrset_values\': '
'[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}, {"location": '
'"body", "name": "items", "description": '
'"\'7.2.3.4.\': invalid hostname (param: '
'{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, '
'\'rrset_name\': u\'sub\', \'rrset_values\': '
'[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}]}')
with self.assertRaises(GandiClientBadRequest) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertIn('"status": "error"', text_type(ctx.exception))
# 401 - Unauthorized.
with requests_mock() as mock:
mock.get(ANY, status_code=401,
text='{"code":401,"message":"The server could not verify '
'that you authorized to access the document you '
'requested. Either you supplied the wrong '
'credentials (e.g., bad api key), or your access '
'token has expired","object":"HTTPUnauthorized",'
'"cause":"Unauthorized"}')
with self.assertRaises(GandiClientUnauthorized) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertIn('"cause":"Unauthorized"', text_type(ctx.exception))
# 403 - Forbidden.
with requests_mock() as mock:
mock.get(ANY, status_code=403,
text='{"code":403,"message":"Access was denied to this '
'resource.","object":"HTTPForbidden","cause":'
'"Forbidden"}')
with self.assertRaises(GandiClientForbidden) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertIn('"cause":"Forbidden"', text_type(ctx.exception))
# 404 - Not Found.
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"code": 404, "message": "The resource could not '
'be found.", "object": "HTTPNotFound", "cause": '
'"Not Found"}')
with self.assertRaises(GandiClientNotFound) as ctx:
zone = Zone('unit.tests.', [])
provider._client.zone(zone)
self.assertIn('"cause": "Not Found"', text_type(ctx.exception))
# General error
with requests_mock() as mock:
mock.get(ANY, status_code=502, text='Things caught fire')
with self.assertRaises(HTTPError) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
# No diffs == no changes
with requests_mock() as mock:
base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \
'/records'
with open('tests/fixtures/gandi-no-changes.json') as fh:
mock.get(base, text=fh.read())
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(14, len(zone.records))
changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes))
del provider._zone_records[zone.name]
# Default Gandi zone file.
with requests_mock() as mock:
base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \
'/records'
with open('tests/fixtures/gandi-records.json') as fh:
mock.get(base, text=fh.read())
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(11, len(zone.records))
changes = self.expected.changes(zone, provider)
self.assertEquals(24, len(changes))
# 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEquals(11, len(again.records))
# bust the cache
del provider._zone_records[zone.name]
def test_apply(self):
provider = GandiProvider('test_id', 'token')
# Zone does not exists but can be created.
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"code": 404, "message": "The resource could not '
'be found.", "object": "HTTPNotFound", "cause": '
'"Not Found"}')
mock.post(ANY, status_code=201,
text='{"message": "Domain Created"}')
plan = provider.plan(self.expected)
provider.apply(plan)
# Zone does not exists and can't be created.
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"code": 404, "message": "The resource could not '
'be found.", "object": "HTTPNotFound", "cause": '
'"Not Found"}')
mock.post(ANY, status_code=404,
text='{"code": 404, "message": "The resource could not '
'be found.", "object": "HTTPNotFound", "cause": '
'"Not Found"}')
with self.assertRaises((GandiClientNotFound,
GandiClientUnknownDomainName)) as ctx:
plan = provider.plan(self.expected)
provider.apply(plan)
self.assertIn('This domain is not registred at Gandi.',
text_type(ctx.exception))
resp = Mock()
resp.json = Mock()
provider._client._request = Mock(return_value=resp)
with open('tests/fixtures/gandi-zone.json') as fh:
zone = fh.read()
# non-existent domain
resp.json.side_effect = [
GandiClientNotFound(resp), # no zone in populate
GandiClientNotFound(resp), # no domain during apply
zone
]
plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded
n = len(self.expected.records) - 4
self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan))
self.assertFalse(plan.exists)
provider._client._request.assert_has_calls([
call('GET', '/livedns/domains/unit.tests/records'),
call('GET', '/livedns/domains/unit.tests'),
call('POST', '/livedns/domains', data={
'fqdn': 'unit.tests',
'zone': {}
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'www.sub',
'rrset_ttl': 300,
'rrset_type': 'A',
'rrset_values': ['2.2.3.6']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'www',
'rrset_ttl': 300,
'rrset_type': 'A',
'rrset_values': ['2.2.3.6']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'txt',
'rrset_ttl': 600,
'rrset_type': 'TXT',
'rrset_values': [
'Bah bah black sheep',
'have you any wool.',
'v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string'
'+with+numb3rs'
]
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'spf',
'rrset_ttl': 600,
'rrset_type': 'SPF',
'rrset_values': ['v=spf1 ip4:192.168.0.1/16-all']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'ptr',
'rrset_ttl': 300,
'rrset_type': 'PTR',
'rrset_values': ['foo.bar.com.']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'mx',
'rrset_ttl': 300,
'rrset_type': 'MX',
'rrset_values': [
'10 smtp-4.unit.tests.',
'20 smtp-2.unit.tests.',
'30 smtp-3.unit.tests.',
'40 smtp-1.unit.tests.'
]
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'excluded',
'rrset_ttl': 3600,
'rrset_type': 'CNAME',
'rrset_values': ['unit.tests.']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'dname',
'rrset_ttl': 300,
'rrset_type': 'DNAME',
'rrset_values': ['unit.tests.']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'cname',
'rrset_ttl': 300,
'rrset_type': 'CNAME',
'rrset_values': ['unit.tests.']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'aaaa',
'rrset_ttl': 600,
'rrset_type': 'AAAA',
'rrset_values': ['2601:644:500:e210:62f8:1dff:feb8:947a']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': '_srv._tcp',
'rrset_ttl': 600,
'rrset_type': 'SRV',
'rrset_values': [
'10 20 30 foo-1.unit.tests.',
'12 20 30 foo-2.unit.tests.'
]
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': '@',
'rrset_ttl': 3600,
'rrset_type': 'SSHFP',
'rrset_values': [
'1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49',
'1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73'
]
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': '@',
'rrset_ttl': 3600,
'rrset_type': 'CAA',
'rrset_values': ['0 issue "ca.unit.tests"']
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': '@',
'rrset_ttl': 300,
'rrset_type': 'A',
'rrset_values': ['1.2.3.4', '1.2.3.5']
})
])
# expected number of total calls
self.assertEquals(17, provider._client._request.call_count)
provider._client._request.reset_mock()
# delete 1 and update 1
provider._client.zone_records = Mock(return_value=[
{
'rrset_name': 'www',
'rrset_ttl': 300,
'rrset_type': 'A',
'rrset_values': ['1.2.3.4']
},
{
'rrset_name': 'www',
'rrset_ttl': 300,
'rrset_type': 'A',
'rrset_values': ['2.2.3.4']
},
{
'rrset_name': 'ttl',
'rrset_ttl': 600,
'rrset_type': 'A',
'rrset_values': ['3.2.3.4']
}
])
# Domain exists, we don't care about return
resp.json.side_effect = ['{}']
wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, 'ttl', {
'ttl': 300,
'type': 'A',
'value': '3.2.3.4'
}))
plan = provider.plan(wanted)
self.assertTrue(plan.exists)
self.assertEquals(2, len(plan.changes))
self.assertEquals(2, provider.apply(plan))
# recreate for update, and deletes for the 2 parts of the other
provider._client._request.assert_has_calls([
call('DELETE', '/livedns/domains/unit.tests/records/www/A'),
call('DELETE', '/livedns/domains/unit.tests/records/ttl/A'),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': 'ttl',
'rrset_ttl': 300,
'rrset_type': 'A',
'rrset_values': ['3.2.3.4']
})
], any_order=True)

+ 2
- 2
tests/test_octodns_provider_mythicbeasts.py View File

@ -171,7 +171,7 @@ class TestMythicBeastsProvider(TestCase):
def test_command_generation(self): def test_command_generation(self):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
zone.add_record(Record.new(zone, 'prawf-alias', {
zone.add_record(Record.new(zone, '', {
'ttl': 60, 'ttl': 60,
'type': 'ALIAS', 'type': 'ALIAS',
'value': 'alias.unit.tests.', 'value': 'alias.unit.tests.',
@ -228,7 +228,7 @@ class TestMythicBeastsProvider(TestCase):
) )
expected_commands = [ expected_commands = [
'ADD prawf-alias.unit.tests 60 ANAME alias.unit.tests.',
'ADD unit.tests 60 ANAME alias.unit.tests.',
'ADD prawf-ns.unit.tests 300 NS alias.unit.tests.', 'ADD prawf-ns.unit.tests 300 NS alias.unit.tests.',
'ADD prawf-ns.unit.tests 300 NS alias2.unit.tests.', 'ADD prawf-ns.unit.tests 300 NS alias2.unit.tests.',
'ADD prawf-a.unit.tests 60 A 1.2.3.4', 'ADD prawf-a.unit.tests 60 A 1.2.3.4',


+ 2
- 2
tests/test_octodns_provider_powerdns.py View File

@ -171,7 +171,7 @@ class TestPowerDnsProvider(TestCase):
expected = Zone('unit.tests.', []) expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config')) source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected) source.populate(expected)
expected_n = len(expected.records) - 2
expected_n = len(expected.records) - 3
self.assertEquals(16, expected_n) self.assertEquals(16, expected_n)
# No diffs == no changes # No diffs == no changes
@ -277,7 +277,7 @@ class TestPowerDnsProvider(TestCase):
expected = Zone('unit.tests.', []) expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config')) source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected) source.populate(expected)
self.assertEquals(18, len(expected.records))
self.assertEquals(19, len(expected.records))
# A small change to a single record # A small change to a single record
with requests_mock() as mock: with requests_mock() as mock:


+ 12
- 11
tests/test_octodns_provider_yaml.py View File

@ -35,7 +35,7 @@ class TestYamlProvider(TestCase):
# without it we see everything # without it we see everything
source.populate(zone) source.populate(zone)
self.assertEquals(18, len(zone.records))
self.assertEquals(19, len(zone.records))
source.populate(dynamic_zone) source.populate(dynamic_zone)
self.assertEquals(5, len(dynamic_zone.records)) self.assertEquals(5, len(dynamic_zone.records))
@ -58,12 +58,12 @@ class TestYamlProvider(TestCase):
# We add everything # We add everything
plan = target.plan(zone) plan = target.plan(zone)
self.assertEquals(15, len([c for c in plan.changes
self.assertEquals(16, len([c for c in plan.changes
if isinstance(c, Create)])) if isinstance(c, Create)]))
self.assertFalse(isfile(yaml_file)) self.assertFalse(isfile(yaml_file))
# Now actually do it # Now actually do it
self.assertEquals(15, target.apply(plan))
self.assertEquals(16, target.apply(plan))
self.assertTrue(isfile(yaml_file)) self.assertTrue(isfile(yaml_file))
# Dynamic plan # Dynamic plan
@ -87,7 +87,7 @@ class TestYamlProvider(TestCase):
# A 2nd sync should still create everything # A 2nd sync should still create everything
plan = target.plan(zone) plan = target.plan(zone)
self.assertEquals(15, len([c for c in plan.changes
self.assertEquals(16, len([c for c in plan.changes
if isinstance(c, Create)])) if isinstance(c, Create)]))
with open(yaml_file) as fh: with open(yaml_file) as fh:
@ -109,6 +109,7 @@ class TestYamlProvider(TestCase):
# these are stored as singular 'value' # these are stored as singular 'value'
self.assertTrue('value' in data.pop('aaaa')) self.assertTrue('value' in data.pop('aaaa'))
self.assertTrue('value' in data.pop('cname')) self.assertTrue('value' in data.pop('cname'))
self.assertTrue('value' in data.pop('dname'))
self.assertTrue('value' in data.pop('included')) self.assertTrue('value' in data.pop('included'))
self.assertTrue('value' in data.pop('ptr')) self.assertTrue('value' in data.pop('ptr'))
self.assertTrue('value' in data.pop('spf')) self.assertTrue('value' in data.pop('spf'))
@ -237,7 +238,7 @@ class TestSplitYamlProvider(TestCase):
# without it we see everything # without it we see everything
source.populate(zone) source.populate(zone)
self.assertEquals(18, len(zone.records))
self.assertEquals(19, len(zone.records))
source.populate(dynamic_zone) source.populate(dynamic_zone)
self.assertEquals(5, len(dynamic_zone.records)) self.assertEquals(5, len(dynamic_zone.records))
@ -251,12 +252,12 @@ class TestSplitYamlProvider(TestCase):
# We add everything # We add everything
plan = target.plan(zone) plan = target.plan(zone)
self.assertEquals(15, len([c for c in plan.changes
self.assertEquals(16, len([c for c in plan.changes
if isinstance(c, Create)])) if isinstance(c, Create)]))
self.assertFalse(isdir(zone_dir)) self.assertFalse(isdir(zone_dir))
# Now actually do it # Now actually do it
self.assertEquals(15, target.apply(plan))
self.assertEquals(16, target.apply(plan))
# Dynamic plan # Dynamic plan
plan = target.plan(dynamic_zone) plan = target.plan(dynamic_zone)
@ -279,7 +280,7 @@ class TestSplitYamlProvider(TestCase):
# A 2nd sync should still create everything # A 2nd sync should still create everything
plan = target.plan(zone) plan = target.plan(zone)
self.assertEquals(15, len([c for c in plan.changes
self.assertEquals(16, len([c for c in plan.changes
if isinstance(c, Create)])) if isinstance(c, Create)]))
yaml_file = join(zone_dir, '$unit.tests.yaml') yaml_file = join(zone_dir, '$unit.tests.yaml')
@ -302,8 +303,8 @@ class TestSplitYamlProvider(TestCase):
self.assertTrue('values' in data.pop(record_name)) self.assertTrue('values' in data.pop(record_name))
# These are stored as singular "value." Again, check each file. # These are stored as singular "value." Again, check each file.
for record_name in ('aaaa', 'cname', 'included', 'ptr', 'spf',
'www.sub', 'www'):
for record_name in ('aaaa', 'cname', 'dname', 'included', 'ptr',
'spf', 'www.sub', 'www'):
yaml_file = join(zone_dir, '{}.yaml'.format(record_name)) yaml_file = join(zone_dir, '{}.yaml'.format(record_name))
self.assertTrue(isfile(yaml_file)) self.assertTrue(isfile(yaml_file))
with open(yaml_file) as fh: with open(yaml_file) as fh:
@ -387,7 +388,7 @@ class TestOverridingYamlProvider(TestCase):
base.populate(zone) base.populate(zone)
got = {r.name: r for r in zone.records} got = {r.name: r for r in zone.records}
self.assertEquals(5, len(got)) self.assertEquals(5, len(got))
# We get the "dynamic" A from the bae config
# We get the "dynamic" A from the base config
self.assertTrue('dynamic' in got['a'].data) self.assertTrue('dynamic' in got['a'].data)
# No added # No added
self.assertFalse('added' in got) self.assertFalse('added' in got)


+ 91
- 6
tests/test_octodns_record.py View File

@ -9,10 +9,10 @@ from six import text_type
from unittest import TestCase from unittest import TestCase
from octodns.record import ARecord, AaaaRecord, AliasRecord, CaaRecord, \ from octodns.record import ARecord, AaaaRecord, AliasRecord, CaaRecord, \
CaaValue, CnameRecord, Create, Delete, GeoValue, MxRecord, MxValue, \
NaptrRecord, NaptrValue, NsRecord, PtrRecord, Record, SshfpRecord, \
SshfpValue, SpfRecord, SrvRecord, SrvValue, TxtRecord, Update, \
ValidationError, _Dynamic, _DynamicPool, _DynamicRule
CaaValue, CnameRecord, DnameRecord, Create, Delete, GeoValue, MxRecord, \
MxValue, NaptrRecord, NaptrValue, NsRecord, PtrRecord, Record, \
SshfpRecord, SshfpValue, SpfRecord, SrvRecord, SrvValue, TxtRecord, \
Update, ValidationError, _Dynamic, _DynamicPool, _DynamicRule
from octodns.zone import Zone from octodns.zone import Zone
from helpers import DynamicProvider, GeoProvider, SimpleProvider from helpers import DynamicProvider, GeoProvider, SimpleProvider
@ -55,6 +55,19 @@ class TestRecord(TestCase):
}) })
self.assertEquals(upper_record.value, lower_record.value) self.assertEquals(upper_record.value, lower_record.value)
def test_dname_lowering_value(self):
upper_record = DnameRecord(self.zone, 'DnameUppwerValue', {
'ttl': 30,
'type': 'DNAME',
'value': 'GITHUB.COM',
})
lower_record = DnameRecord(self.zone, 'DnameLowerValue', {
'ttl': 30,
'type': 'DNAME',
'value': 'github.com',
})
self.assertEquals(upper_record.value, lower_record.value)
def test_ptr_lowering_value(self): def test_ptr_lowering_value(self):
upper_record = PtrRecord(self.zone, 'PtrUppwerValue', { upper_record = PtrRecord(self.zone, 'PtrUppwerValue', {
'ttl': 30, 'ttl': 30,
@ -362,6 +375,10 @@ class TestRecord(TestCase):
self.assertSingleValue(CnameRecord, 'target.foo.com.', self.assertSingleValue(CnameRecord, 'target.foo.com.',
'other.foo.com.') 'other.foo.com.')
def test_dname(self):
self.assertSingleValue(DnameRecord, 'target.foo.com.',
'other.foo.com.')
def test_mx(self): def test_mx(self):
a_values = [{ a_values = [{
'preference': 10, 'preference': 10,
@ -796,6 +813,39 @@ class TestRecord(TestCase):
}) })
self.assertTrue('Unknown record type' in text_type(ctx.exception)) self.assertTrue('Unknown record type' in text_type(ctx.exception))
def test_record_copy(self):
a = Record.new(self.zone, 'a', {
'ttl': 44,
'type': 'A',
'value': '1.2.3.4',
})
# Identical copy.
b = a.copy()
self.assertIsInstance(b, ARecord)
self.assertEquals('unit.tests.', b.zone.name)
self.assertEquals('a', b.name)
self.assertEquals('A', b._type)
self.assertEquals(['1.2.3.4'], b.values)
# Copy with another zone object.
c_zone = Zone('other.tests.', [])
c = a.copy(c_zone)
self.assertIsInstance(c, ARecord)
self.assertEquals('other.tests.', c.zone.name)
self.assertEquals('a', c.name)
self.assertEquals('A', c._type)
self.assertEquals(['1.2.3.4'], c.values)
# Record with no record type specified in data.
d_data = {
'ttl': 600,
'values': ['just a test']
}
d = TxtRecord(self.zone, 'txt', d_data)
d.copy()
self.assertEquals('TXT', d._type)
def test_change(self): def test_change(self):
existing = Record.new(self.zone, 'txt', { existing = Record.new(self.zone, 'txt', {
'ttl': 44, 'ttl': 44,
@ -1693,6 +1743,16 @@ class TestRecordValidation(TestCase):
'value': 'foo.bar.com.', 'value': 'foo.bar.com.',
}) })
# root only
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'nope', {
'type': 'ALIAS',
'ttl': 600,
'value': 'foo.bar.com.',
})
self.assertEquals(['non-root ALIAS not allowed'],
ctx.exception.reasons)
# missing value # missing value
with self.assertRaises(ValidationError) as ctx: with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', { Record.new(self.zone, '', {
@ -1703,7 +1763,7 @@ class TestRecordValidation(TestCase):
# missing value # missing value
with self.assertRaises(ValidationError) as ctx: with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'www', {
Record.new(self.zone, '', {
'type': 'ALIAS', 'type': 'ALIAS',
'ttl': 600, 'ttl': 600,
'value': None 'value': None
@ -1712,7 +1772,7 @@ class TestRecordValidation(TestCase):
# empty value # empty value
with self.assertRaises(ValidationError) as ctx: with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'www', {
Record.new(self.zone, '', {
'type': 'ALIAS', 'type': 'ALIAS',
'ttl': 600, 'ttl': 600,
'value': '' 'value': ''
@ -1825,6 +1885,31 @@ class TestRecordValidation(TestCase):
self.assertEquals(['CNAME value "foo.bar.com" missing trailing .'], self.assertEquals(['CNAME value "foo.bar.com" missing trailing .'],
ctx.exception.reasons) ctx.exception.reasons)
def test_DNAME(self):
# A valid DNAME record.
Record.new(self.zone, 'sub', {
'type': 'DNAME',
'ttl': 600,
'value': 'foo.bar.com.',
})
# A DNAME record can be present at the zone APEX.
Record.new(self.zone, '', {
'type': 'DNAME',
'ttl': 600,
'value': 'foo.bar.com.',
})
# missing trailing .
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'www', {
'type': 'DNAME',
'ttl': 600,
'value': 'foo.bar.com',
})
self.assertEquals(['DNAME value "foo.bar.com" missing trailing .'],
ctx.exception.reasons)
def test_MX(self): def test_MX(self):
# doesn't blow up # doesn't blow up
Record.new(self.zone, '', { Record.new(self.zone, '', {


Loading…
Cancel
Save