Browse Source

Merge branch 'master' into master

pull/587/head
Arunothia Marappan 5 years ago
committed by GitHub
parent
commit
262eb37254
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 2856 additions and 198 deletions
  1. +3
    -1
      .github/workflows/main.yml
  2. +30
    -5
      CHANGELOG.md
  3. +1
    -1
      CONTRIBUTING.md
  4. +8
    -4
      MANIFEST.in
  5. +13
    -6
      README.md
  6. +1
    -1
      docs/geo_records.md
  7. +55
    -1
      docs/records.md
  8. +1
    -1
      octodns/__init__.py
  9. +14
    -1
      octodns/cmds/compare.py
  10. +1
    -2
      octodns/cmds/report.py
  11. +7
    -6
      octodns/cmds/sync.py
  12. +120
    -22
      octodns/manager.py
  13. +3
    -12
      octodns/provider/azuredns.py
  14. +445
    -0
      octodns/provider/easydns.py
  15. +378
    -0
      octodns/provider/gandi.py
  16. +7
    -3
      octodns/provider/ovh.py
  17. +4
    -1
      octodns/provider/powerdns.py
  18. +7
    -1
      octodns/provider/ultra.py
  19. +2
    -2
      octodns/provider/yaml.py
  20. +39
    -4
      octodns/record/__init__.py
  21. +16
    -1
      octodns/source/axfr.py
  22. +5
    -4
      requirements.txt
  23. +1
    -1
      script/coverage
  24. +1
    -1
      setup.py
  25. +21
    -0
      tests/config/alias-zone-loop.yaml
  26. +19
    -0
      tests/config/simple-alias-zone.yaml
  27. +5
    -0
      tests/config/split/unit.tests./dname.yaml
  28. +4
    -0
      tests/config/unit.tests.yaml
  29. +18
    -0
      tests/config/unknown-source-zone.yaml
  30. +0
    -37
      tests/fixtures/constellix-records.json
  31. +0
    -14
      tests/fixtures/dnsmadeeasy-records.json
  32. +274
    -0
      tests/fixtures/easydns-records.json
  33. +136
    -0
      tests/fixtures/gandi-no-changes.json
  34. +111
    -0
      tests/fixtures/gandi-records.json
  35. +7
    -0
      tests/fixtures/gandi-zone.json
  36. +1
    -1
      tests/fixtures/ultra-records-page-1.json
  37. +10
    -2
      tests/fixtures/ultra-records-page-2.json
  38. +1
    -1
      tests/fixtures/ultra-zones-page-1.json
  39. +77
    -7
      tests/test_octodns_manager.py
  40. +1
    -7
      tests/test_octodns_provider_azuredns.py
  41. +4
    -10
      tests/test_octodns_provider_constellix.py
  42. +1
    -1
      tests/test_octodns_provider_digitalocean.py
  43. +1
    -1
      tests/test_octodns_provider_dnsimple.py
  44. +4
    -10
      tests/test_octodns_provider_dnsmadeeasy.py
  45. +448
    -0
      tests/test_octodns_provider_easydns.py
  46. +361
    -0
      tests/test_octodns_provider_gandi.py
  47. +2
    -2
      tests/test_octodns_provider_mythicbeasts.py
  48. +16
    -2
      tests/test_octodns_provider_powerdns.py
  49. +12
    -11
      tests/test_octodns_provider_yaml.py
  50. +153
    -8
      tests/test_octodns_record.py
  51. +3
    -3
      tests/test_octodns_source_axfr.py
  52. +4
    -0
      tests/zones/unit.tests.

+ 3
- 1
.github/workflows/main.yml View File

@ -6,7 +6,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [2.7, 3.7]
# Tested versions based on dates in https://devguide.python.org/devcycle/#end-of-life-branches,
# with the addition of 2.7 b/c it's still if pretty wide active use.
python-version: [2.7, 3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@master
- name: Setup python


+ 30
- 5
CHANGELOG.md View File

@ -1,6 +1,31 @@
## v0.9.11 - 2020-??-?? - ???????????????
## v0.9.11 - 2020-11-05 - We still don't know edition
* Added support for TCP health checking to dynamic records
#### Noteworthy changtes
* ALIAS records only allowed at the root of zones - see `leient` in record docs
for work-arounds if you really need them.
#### New Providers
* Gandi LiveDNS
* UltraDNS
* easyDNS
#### Stuff
* Add support for zones aliases
* octodns-compare: Prefix filtering and status code on on mismatch
* Implement octodns-sync --source
* Adding environment variable record injection
* Add support for wildcard SRV records, as shown in RFC 2782
* Add healthcheck option 'request_interval' for Route53 provider
* NS1 georegion, country, and catchall need to be separate groups
* Add the ability to mark a zone as lenient
* Add support for geo-targeting of CA provinces
* Update geo_data to pick up a couple renames
* Cloudflare: Add PTR Support, update rate-limit handling and pagination
* Support PowerDNS 4.3.x
* Added support for TCP health checking of dynamic records
## v0.9.10 - 2020-04-20 - Dynamic NS1 and lots of misc
@ -30,7 +55,7 @@
* Explicit ordering of changes by (name, type) to address inconsistent
ordering for a number of providers that just convert changes into API
calls as they come. Python 2 sets ordered consistently, Python 3 they do
not. https://github.com/github/octodns/pull/384/commits/7958233fccf9ea22d95e2fd06c48d7d0a4529e26
not. https://github.com/octodns/octodns/pull/384/commits/7958233fccf9ea22d95e2fd06c48d7d0a4529e26
* Route53 `_mod_keyer` ordering wasn't 100% complete and thus unreliable and
random in Python 3. This has been addressed and may result in value
reordering on next plan, no actual changes in behavior should occur.
@ -127,10 +152,10 @@ recreating all health checks. This process has been tested pretty thoroughly to
try and ensure a seemless upgrade without any traffic shifting around. It's
probably best to take extra care when updating and to try and make sure that
all health checks are passing before the first sync with `--doit`. See
[#67](https://github.com/github/octodns/pull/67) for more information.
[#67](https://github.com/octodns/octodns/pull/67) for more information.
* Major update to geo healthchecks to allow configuring host (header), path,
protocol, and port [#67](https://github.com/github/octodns/pull/67)
protocol, and port [#67](https://github.com/octodns/octodns/pull/67)
* SSHFP algorithm type 4
* NS1 and DNSimple support skipping unsupported record types
* Revert back to old style setup.py & requirements.txt, setup.cfg was


+ 1
- 1
CONTRIBUTING.md View File

@ -4,7 +4,7 @@ Hi there! We're thrilled that you'd like to contribute to OctoDNS. Your help is
Please note that this project adheres to the [Contributor Covenant Code of Conduct](/CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
If you have questions, or you'd like to check with us before embarking on a major development effort, please [open an issue](https://github.com/github/octodns/issues/new).
If you have questions, or you'd like to check with us before embarking on a major development effort, please [open an issue](https://github.com/octodns/octodns/issues/new).
## How to contribute


+ 8
- 4
MANIFEST.in View File

@ -1,7 +1,11 @@
include README.md
include CHANGELOG.md
include CODE_OF_CONDUCT.md
include CONTRIBUTING.md
include LICENSE
include docs/*
include octodns/*
include README.md
include requirements-dev.txt
include requirements.txt
include script/*
include tests/*
recursive-include docs *.png *.md
recursive-include tests *.json *.py *.txt *.yaml
recursive-include tests/zones *

+ 13
- 6
README.md View File

@ -1,4 +1,4 @@
<img src="https://raw.githubusercontent.com/github/octodns/master/docs/logos/octodns-logo.png?" height=251 width=404>
<img src="https://raw.githubusercontent.com/octodns/octodns/master/docs/logos/octodns-logo.png?" height=251 width=404>
## DNS as code - Tools for managing DNS across multiple providers
@ -79,6 +79,9 @@ zones:
targets:
- dyn
- 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.
@ -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.
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.
`config/example.com.yaml`
@ -186,11 +191,13 @@ The above command pulled the existing data out of Route53 and placed the results
| [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
| [DnsimpleProvider](/octodns/provider/dnsimple.py) | | All | No | CAA tags restricted |
| [DynProvider](/octodns/provider/dyn.py) | dyn | All | Both | |
| [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 | |
| [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 | |
| [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 | No CNAME support, missing `NA` geo target |
| [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | Missing `NA` geo target |
| [OVH](/octodns/provider/ovh.py) | ovh | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT, DKIM | No | |
| [PowerDnsProvider](/octodns/provider/powerdns.py) | | All | No | |
| [Rackspace](/octodns/provider/rackspace.py) | | A, AAAA, ALIAS, CNAME, MX, NS, PTR, SPF, TXT | No | |
@ -198,8 +205,8 @@ The above command pulled the existing data out of Route53 and placed the results
| [Selectel](/octodns/provider/selectel.py) | | A, AAAA, CNAME, MX, NS, SPF, SRV, TXT | No | |
| [Transip](/octodns/provider/transip.py) | transip | A, AAAA, CNAME, MX, SRV, SPF, TXT, SSHFP, CAA | No | |
| [UltraDns](/octodns/provider/ultra.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | |
| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [ZoneFileSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [ZoneFileSource](/octodns/source/axfr.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [TinyDnsFileSource](/octodns/source/tinydns.py) | | A, CNAME, MX, NS, PTR | No | read-only |
| [YamlProvider](/octodns/provider/yaml.py) | | All | Yes | config |
@ -277,13 +284,13 @@ Please see our [contributing document](/CONTRIBUTING.md) if you would like to pa
## Getting help
If you have a problem or suggestion, please [open an issue](https://github.com/github/octodns/issues/new) in this repository, and we will do our best to help. Please note that this project adheres to the [Contributor Covenant Code of Conduct](/CODE_OF_CONDUCT.md).
If you have a problem or suggestion, please [open an issue](https://github.com/octodns/octodns/issues/new) in this repository, and we will do our best to help. Please note that this project adheres to the [Contributor Covenant Code of Conduct](/CODE_OF_CONDUCT.md).
## License
OctoDNS is licensed under the [MIT license](LICENSE).
The MIT license grant is not for GitHub's trademarks, which include the logo designs. GitHub reserves all trademark and copyright rights in and to all GitHub trademarks. GitHub's logos include, for instance, the stylized designs that include "logo" in the file title in the following folder: https://github.com/github/octodns/tree/master/docs/logos/
The MIT license grant is not for GitHub's trademarks, which include the logo designs. GitHub reserves all trademark and copyright rights in and to all GitHub trademarks. GitHub's logos include, for instance, the stylized designs that include "logo" in the file title in the following folder: https://github.com/octodns/octodns/tree/master/docs/logos/
GitHub® and its stylized versions and the Invertocat mark are GitHub's Trademarks or registered Trademarks. When using GitHub's logos, be sure to follow the GitHub logo guidelines.


+ 1
- 1
docs/geo_records.md View File

@ -1,6 +1,6 @@
## 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.


+ 55
- 1
docs/records.md View File

@ -6,14 +6,17 @@ OctoDNS supports the following record types:
* `A`
* `AAAA`
* `ALIAS`
* `CAA`
* `CNAME`
* `DNAME`
* `MX`
* `NAPTR`
* `NS`
* `PTR`
* `SSHFP`
* `SPF`
* `SRV`
* `SSHFP`
* `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.
@ -81,3 +84,54 @@ 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.
`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
```
#### Restrict Record manipulations
OctoDNS currently provides the ability to limit the number of updates/deletes on
DNS records by configuring a percentage of allowed operations as a threshold.
If left unconfigured, suitable defaults take over instead. In the below example,
the Dyn provider is configured with limits of 40% on both update and
delete operations over all the records present.
````yaml
dyn:
class: octodns.provider.dyn.DynProvider
update_pcent_threshold: 0.4
delete_pcent_threshold: 0.4
````

+ 1
- 1
octodns/__init__.py View File

@ -3,4 +3,4 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals
__VERSION__ = '0.9.10'
__VERSION__ = '0.9.11'

+ 14
- 1
octodns/cmds/compare.py View File

@ -7,6 +7,7 @@ from __future__ import absolute_import, division, print_function, \
unicode_literals
from pprint import pprint
import sys
from octodns.cmds.args import ArgumentParser
from octodns.manager import Manager
@ -23,13 +24,25 @@ def main():
help='Second source(s) to pull data from')
parser.add_argument('--zone', default=None, required=True,
help='Zone to compare')
parser.add_argument('--ignore-prefix', default=None, required=False,
help='Record prefix to ignore from list of changes')
args = parser.parse_args()
manager = Manager(args.config_file)
changes = manager.compare(args.a, args.b, args.zone)
# Filter changes list based on ignore-prefix argument if present
if args.ignore_prefix:
pattern = args.ignore_prefix
changes = [c for c in changes
if not c.record.fqdn.startswith(pattern)]
pprint(changes)
# Exit with non-zero exit code if changes exist
if len(changes):
sys.exit(1)
if __name__ == '__main__':
main()

+ 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.manager import Manager
from octodns.zone import Zone
class AsyncResolver(Resolver):
@ -56,7 +55,7 @@ def main():
except KeyError as e:
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:
source.populate(zone)


+ 7
- 6
octodns/cmds/sync.py View File

@ -25,18 +25,19 @@ def main():
parser.add_argument('zone', nargs='*', default=[],
help='Limit sync to the specified zone(s)')
# --sources isn't an option here b/c filtering sources out would be super
# 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
parser.add_argument('--source', default=[], action='append',
help='Limit sync to zones with the specified '
'source(s) (all sources will be synchronized for the '
'selected zones)')
parser.add_argument('--target', default=[], action='append',
help='Limit sync to the specified target(s)')
args = parser.parse_args()
manager = Manager(args.config_file)
manager.sync(eligible_zones=args.zone, eligible_targets=args.target,
dry_run=not args.doit, force=args.force)
manager.sync(eligible_zones=args.zone, eligible_sources=args.source,
eligible_targets=args.target, dry_run=not args.doit,
force=args.force)
if __name__ == '__main__':


+ 120
- 22
octodns/manager.py View File

@ -222,21 +222,31 @@ class Manager(object):
self.log.debug('configured_sub_zones: subs=%s', 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',
zone_name, lenient)
zone = Zone(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)
plans = []
@ -253,10 +263,11 @@ class Manager(object):
if 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_targets=[], dry_run=True,
force=False):
def sync(self, eligible_zones=[], eligible_sources=[], eligible_targets=[],
dry_run=True, force=False):
self.log.info('sync: eligible_zones=%s, eligible_targets=%s, '
'dry_run=%s, force=%s', eligible_zones, eligible_targets,
dry_run, force)
@ -265,9 +276,32 @@ class Manager(object):
if eligible_zones:
zones = [z for z in zones if z[0] in eligible_zones]
aliased_zones = {}
futures = []
for zone_name, config in zones:
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)
try:
sources = config['sources']
@ -280,6 +314,12 @@ class Manager(object):
except KeyError:
raise ManagerException('Zone {} is missing targets'
.format(zone_name))
if (eligible_sources and not
[s for s in sources if s in eligible_sources]):
self.log.info('sync: no eligible sources, skipping')
continue
if eligible_targets:
targets = [t for t in targets if t in eligible_targets]
@ -321,9 +361,38 @@ class Manager(object):
zone_name, sources,
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]
try:
desired_config = desired[zone_source]
except KeyError:
raise ManagerException('Zone {} cannot be sync without zone '
'{} sinced it is aliased'
.format(zone_name, zone_source))
futures.append(self._executor.submit(
self._populate_and_plan,
zone_name,
[],
[self.providers[t] for t in source_config['targets']],
desired=desired_config,
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
# children zones before parents which should allow us to more safely
@ -371,12 +440,11 @@ class Manager(object):
except KeyError as e:
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:
source.populate(za)
zb = Zone(zone, sub_zones)
zb = self.get_zone(zone)
for source in b:
source.populate(zb)
@ -415,6 +483,25 @@ class Manager(object):
for zone_name, config in self.config['zones'].items():
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:
sources = config['sources']
except KeyError:
@ -422,9 +509,9 @@ class Manager(object):
.format(zone_name))
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 = []
for source in sources:
collected.append(self.providers[source])
@ -436,3 +523,14 @@ class Manager(object):
for source in sources:
if isinstance(source, YamlProvider):
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))

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

@ -416,14 +416,8 @@ class AzureProvider(BaseProvider):
:type azrecord: azure.mgmt.dns.models.RecordSet
:type return: dict
CNAME and PTR both use the catch block to catch possible empty
records. Refer to population comment.
'''
try:
return {'value': _check_endswith_dot(azrecord.cname_record.cname)}
except:
return {'value': '.'}
return {'value': _check_endswith_dot(azrecord.cname_record.cname)}
def _data_for_MX(self, azrecord):
return {'values': [{'preference': ar.preference,
@ -435,11 +429,8 @@ class AzureProvider(BaseProvider):
return {'values': [_check_endswith_dot(val) for val in vals]}
def _data_for_PTR(self, azrecord):
try:
ptrdname = azrecord.ptr_records[0].ptrdname
return {'value': _check_endswith_dot(ptrdname)}
except:
return {'value': '.'}
ptrdname = azrecord.ptr_records[0].ptrdname
return {'value': _check_endswith_dot(ptrdname)}
def _data_for_SRV(self, azrecord):
return {'values': [{'priority': ar.priority, 'weight': ar.weight,


+ 445
- 0
octodns/provider/easydns.py View File

@ -0,0 +1,445 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from collections import defaultdict
from requests import Session
from time import sleep
import logging
import base64
from ..record import Record
from .base import BaseProvider
class EasyDNSClientException(Exception):
pass
class EasyDNSClientBadRequest(EasyDNSClientException):
def __init__(self):
super(EasyDNSClientBadRequest, self).__init__('Bad request')
class EasyDNSClientNotFound(EasyDNSClientException):
def __init__(self):
super(EasyDNSClientNotFound, self).__init__('Not Found')
class EasyDNSClientUnauthorized(EasyDNSClientException):
def __init__(self):
super(EasyDNSClientUnauthorized, self).__init__('Unauthorized')
class EasyDNSClient(object):
# EasyDNS Sandbox API
SANDBOX = 'https://sandbox.rest.easydns.net'
# EasyDNS Live API
LIVE = 'https://rest.easydns.net'
# Default Currency CAD
default_currency = 'CAD'
# Domain Portfolio
domain_portfolio = 'myport'
def __init__(self, token, api_key, currency, portfolio, sandbox):
self.log = logging.getLogger('EasyDNSProvider[{}]'.format(id))
self.token = token
self.api_key = api_key
self.default_currency = currency
self.domain_portfolio = portfolio
self.apienv = 'sandbox' if sandbox else 'live'
auth_key = '{}:{}'.format(self.token, self.api_key)
self.auth_key = base64.b64encode(auth_key.encode("utf-8"))
self.base_path = self.SANDBOX if sandbox else self.LIVE
sess = Session()
sess.headers.update({'Authorization': 'Basic {}'
.format(self.auth_key)})
sess.headers.update({'accept': 'application/json'})
self._sess = sess
def _request(self, method, path, params=None, data=None):
url = '{}{}'.format(self.base_path, path)
resp = self._sess.request(method, url, params=params, json=data)
if resp.status_code == 400:
self.log.debug('Response code 400, path=%s', path)
if method == 'GET' and path[:8] == '/domain/':
raise EasyDNSClientNotFound()
raise EasyDNSClientBadRequest()
if resp.status_code == 401:
raise EasyDNSClientUnauthorized()
if resp.status_code == 403 or resp.status_code == 404:
raise EasyDNSClientNotFound()
resp.raise_for_status()
return resp
def domain(self, name):
path = '/domain/{}'.format(name)
return self._request('GET', path).json()
def domain_create(self, name):
# EasyDNS allows for new domains to be created for the purpose of DNS
# only, or with domain registration. This function creates a DNS only
# record expectig the domain to be registered already
path = '/domains/add/{}'.format(name)
domain_data = {'service': 'dns',
'term': 1,
'dns_only': 1,
'portfolio': self.domain_portfolio,
'currency': self.default_currency}
self._request('PUT', path, data=domain_data).json()
# EasyDNS creates default records for MX, A and CNAME for new domains,
# we need to delete those default record so we can sync with the source
# records, first we'll sleep for a second before gathering new records
# We also create default NS records, but they won't be deleted
sleep(1)
records = self.records(name, True)
for record in records:
if record['host'] in ('', 'www') \
and record['type'] in ('A', 'MX', 'CNAME'):
self.record_delete(name, record['id'])
def records(self, zone_name, raw=False):
if raw:
path = '/zones/records/all/{}'.format(zone_name)
else:
path = '/zones/records/parsed/{}'.format(zone_name)
ret = []
resp = self._request('GET', path).json()
ret += resp['data']
for record in ret:
# change any apex record to empty string
if record['host'] == '@':
record['host'] = ''
# change any apex value to zone name
if record['rdata'] == '@':
record['rdata'] = '{}.'.format(zone_name)
return ret
def record_create(self, zone_name, params):
path = '/zones/records/add/{}/{}'.format(zone_name, params['type'])
# change empty name string to @, EasyDNS uses @ for apex record names
params['host'] = params['name']
if params['host'] == '':
params['host'] = '@'
self._request('PUT', path, data=params)
def record_delete(self, zone_name, record_id):
path = '/zones/records/{}/{}'.format(zone_name, record_id)
self._request('DELETE', path)
class EasyDNSProvider(BaseProvider):
'''
EasyDNS provider using API v3
easydns:
class: octodns.provider.easydns.EasyDNSProvider
# Your EasyDNS API token (required)
token: foo
# Your EasyDNS API Key (required)
api_key: bar
# Use SandBox or Live environment, optional, defaults to live
sandbox: False
# Currency to use for creating domains, default CAD
default_currency: CAD
# Domain Portfolio under which to create domains
portfolio: myport
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'TXT',
'SRV', 'NAPTR'))
def __init__(self, id, token, api_key, currency='CAD', portfolio='myport',
sandbox=False, *args, **kwargs):
self.log = logging.getLogger('EasyDNSProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, token=***', id)
super(EasyDNSProvider, self).__init__(id, *args, **kwargs)
self._client = EasyDNSClient(token, api_key, currency, portfolio,
sandbox)
self._zone_records = {}
def _data_for_multiple(self, _type, records):
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': [r['rdata'] for r in records]
}
_data_for_A = _data_for_multiple
_data_for_AAAA = _data_for_multiple
def _data_for_CAA(self, _type, records):
values = []
for record in records:
try:
flags, tag, value = record['rdata'].split(' ', 2)
except ValueError:
continue
values.append({
'flags': flags,
'tag': tag,
'value': value,
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_NAPTR(self, _type, records):
values = []
for record in records:
try:
order, preference, flags, service, regexp, replacement = \
record['rdata'].split(' ', 5)
except ValueError:
continue
values.append({
'flags': flags[1:-1],
'order': order,
'preference': preference,
'regexp': regexp[1:-1],
'replacement': replacement,
'service': service[1:-1],
})
return {
'type': _type,
'ttl': records[0]['ttl'],
'values': values
}
def _data_for_CNAME(self, _type, records):
record = records[0]
return {
'ttl': record['ttl'],
'type': _type,
'value': '{}'.format(record['rdata'])
}
def _data_for_MX(self, _type, records):
values = []
for record in records:
values.append({
'preference': record['prio'],
'exchange': '{}'.format(record['rdata'])
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_NS(self, _type, records):
values = []
for record in records:
data = '{}'.format(record['rdata'])
values.append(data)
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values,
}
def _data_for_SRV(self, _type, records):
values = []
record = records[0]
for record in records:
try:
priority, weight, port, target = record['rdata'].split(' ', 3)
except ValueError:
rdata = record['rdata'].split(' ', 3)
priority = 0
weight = 0
port = 0
target = ''
if len(rdata) != 0 and rdata[0] != '':
priority = rdata[0]
if len(rdata) >= 2:
weight = rdata[1]
if len(rdata) >= 3:
port = rdata[2]
values.append({
'port': int(port),
'priority': int(priority),
'target': target,
'weight': int(weight)
})
return {
'type': _type,
'ttl': records[0]['ttl'],
'values': values
}
def _data_for_TXT(self, _type, records):
values = ['"' + value['rdata'].replace(';', '\\;') +
'"' for value in records]
return {
'ttl': records[0]['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.records(zone.name[:-1])
except EasyDNSClientNotFound:
return []
return self._zone_records[zone.name]
def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient)
values = defaultdict(lambda: defaultdict(list))
for record in self.zone_records(zone):
_type = record['type']
if _type not in self.SUPPORTS:
self.log.warning('populate: skipping unsupported %s record',
_type)
continue
values[record['host']][record['type']].append(record)
before = len(zone.records)
for name, types in values.items():
for _type, records in types.items():
data_for = getattr(self, '_data_for_{}'.format(_type))
record = Record.new(zone, name, data_for(_type, records),
source=self, lenient=lenient)
zone.add_record(record, 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 _params_for_multiple(self, record):
for value in record.values:
yield {
'rdata': value,
'name': record.name,
'ttl': record.ttl,
'type': record._type
}
_params_for_A = _params_for_multiple
_params_for_AAAA = _params_for_multiple
_params_for_NS = _params_for_multiple
def _params_for_CAA(self, record):
for value in record.values:
yield {
'rdata': "{} {} {}".format(value.flags, value.tag,
value.value),
'name': record.name,
'ttl': record.ttl,
'type': record._type
}
def _params_for_NAPTR(self, record):
for value in record.values:
content = '{} {} "{}" "{}" "{}" {}'.format(value.order,
value.preference,
value.flags,
value.service,
value.regexp,
value.replacement)
yield {
'rdata': content,
'name': record.name,
'ttl': record.ttl,
'type': record._type
}
def _params_for_single(self, record):
yield {
'rdata': record.value,
'name': record.name,
'ttl': record.ttl,
'type': record._type
}
_params_for_CNAME = _params_for_single
def _params_for_MX(self, record):
for value in record.values:
yield {
'rdata': value.exchange,
'name': record.name,
'prio': value.preference,
'ttl': record.ttl,
'type': record._type
}
def _params_for_SRV(self, record):
for value in record.values:
yield {
'rdata': "{} {} {} {}".format(value.priority, value.port,
value.weight, value.target),
'name': record.name,
'ttl': record.ttl,
'type': record._type,
}
def _params_for_TXT(self, record):
for value in record.values:
yield {
'rdata': '"' + value.replace('\\;', ';') + '"',
'name': record.name,
'ttl': record.ttl,
'type': record._type
}
def _apply_Create(self, change):
new = change.new
params_for = getattr(self, '_params_for_{}'.format(new._type))
for params in params_for(new):
self._client.record_create(new.zone.name[:-1], params)
def _apply_Update(self, change):
self._apply_Delete(change)
self._apply_Create(change)
def _apply_Delete(self, change):
existing = change.existing
zone = existing.zone
for record in self.zone_records(zone):
self.log.debug('apply_Delete: zone=%s, type=%s, host=%s', zone,
record['type'], record['host'])
if existing.name == record['host'] and \
existing._type == record['type']:
self._client.record_delete(zone.name[:-1], record['id'])
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
len(changes))
domain_name = desired.name[:-1]
try:
self._client.domain(domain_name)
except EasyDNSClientNotFound:
self.log.debug('_apply: no matching zone, creating domain')
self._client.domain_create(domain_name)
for change in changes:
class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name))(change)
# Clear out the cache if any
self._zone_records.pop(desired.name, None)

+ 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 '
'registered 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)

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

@ -370,11 +370,15 @@ class OvhProvider(BaseProvider):
@staticmethod
def _is_valid_dkim_key(key):
result = True
base64_decode = getattr(base64, 'decodestring', None)
base64_decode = getattr(base64, 'decodebytes', base64_decode)
try:
base64.decodestring(bytearray(key, 'utf-8'))
result = base64_decode(bytearray(key, 'utf-8'))
except binascii.Error:
return False
return True
result = False
return result
def get_records(self, zone_name):
"""


+ 4
- 1
octodns/provider/powerdns.py View File

@ -183,7 +183,10 @@ class PowerDnsBaseProvider(BaseProvider):
version = resp.json()['version']
self.log.debug('powerdns_version: got version %s from server',
version)
self._powerdns_version = [int(p) for p in version.split('.')]
# The extra `-` split is to handle pre-release and source built
# versions like 4.5.0-alpha0.435.master.gcb114252b
self._powerdns_version = [
int(p.split('-')[0]) for p in version.split('.')[:3]]
return self._powerdns_version


+ 7
- 1
octodns/provider/ultra.py View File

@ -287,7 +287,13 @@ class UltraProvider(BaseProvider):
name = zone.hostname_from_fqdn(record['ownerName'])
if record['rrtype'] == 'SOA (6)':
continue
_type = self.RECORDS_TO_TYPE[record['rrtype']]
try:
_type = self.RECORDS_TO_TYPE[record['rrtype']]
except KeyError:
self.log.warning('populate: ignoring record with '
'unsupported rrtype, %s %s',
name, record['rrtype'])
continue
values[name][_type] = record
for name, types in values.items():


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

@ -104,8 +104,8 @@ class YamlProvider(BaseProvider):
'''
SUPPORTS_GEO = 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,
populate_should_replace=False, *args, **kwargs):


+ 39
- 4
octodns/record/__init__.py View File

@ -10,6 +10,7 @@ from logging import getLogger
import re
from six import string_types, text_type
from fqdn import FQDN
from ..equality import EqualityTupleMixin
from .geo import GeoCodes
@ -95,6 +96,7 @@ class Record(EqualityTupleMixin):
'ALIAS': AliasRecord,
'CAA': CaaRecord,
'CNAME': CnameRecord,
'DNAME': DnameRecord,
'MX': MxRecord,
'NAPTR': NaptrRecord,
'NS': NsRecord,
@ -125,10 +127,11 @@ class Record(EqualityTupleMixin):
if n > 253:
reasons.append('invalid fqdn, "{}" is too long at {} chars, max '
'is 253'.format(fqdn, n))
n = len(name)
if n > 63:
reasons.append('invalid name, "{}" is too long at {} chars, max '
'is 63'.format(name, n))
for label in name.split('.'):
n = len(label)
if n > 63:
reasons.append('invalid label, "{}" is too long at {} chars, '
'max is 63'.format(label, n))
try:
ttl = int(data['ttl'])
if ttl < 0:
@ -218,6 +221,18 @@ class Record(EqualityTupleMixin):
if self.ttl != other.ttl:
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
# equivalent if they have the same name & _type. Values are ignored. This
# is useful when computing diffs/changes.
@ -743,6 +758,9 @@ class _TargetValue(object):
reasons.append('empty value')
elif not data:
reasons.append('missing value')
elif not FQDN(data, allow_underscores=True).is_valid:
reasons.append('{} value "{}" is not a valid FQDN'
.format(_type, data))
elif not data.endswith('.'):
reasons.append('{} value "{}" missing trailing .'
.format(_type, data))
@ -759,6 +777,10 @@ class CnameValue(_TargetValue):
pass
class DnameValue(_TargetValue):
pass
class ARecord(_DynamicMixin, _GeoMixin, Record):
_type = 'A'
_value_type = Ipv4List
@ -777,6 +799,14 @@ class AliasRecord(_ValueMixin, Record):
_type = 'ALIAS'
_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):
# https://tools.ietf.org/html/rfc6844#page-5
@ -842,6 +872,11 @@ class CnameRecord(_DynamicMixin, _ValueMixin, Record):
return reasons
class DnameRecord(_DynamicMixin, _ValueMixin, Record):
_type = 'DNAME'
_value_type = DnameValue
class MxValue(EqualityTupleMixin):
@classmethod


+ 16
- 1
octodns/source/axfr.py View File

@ -26,7 +26,7 @@ class AxfrBaseSource(BaseSource):
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SPF',
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'PTR', 'SPF',
'SRV', 'TXT'))
def __init__(self, id):
@ -43,6 +43,21 @@ class AxfrBaseSource(BaseSource):
_data_for_AAAA = _data_for_multiple
_data_for_NS = _data_for_multiple
def _data_for_CAA(self, _type, records):
values = []
for record in records:
flags, tag, value = record['value'].split(' ', 2)
values.append({
'flags': flags,
'tag': tag,
'value': value.replace('"', '')
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_MX(self, _type, records):
values = []
for record in records:


+ 5
- 4
requirements.txt View File

@ -1,14 +1,15 @@
PyYaml==5.3.1
azure-common==1.1.25
azure-mgmt-dns==3.0.0
boto3==1.14.14
botocore==1.17.14
boto3==1.15.9
botocore==1.18.9
dnspython==1.16.0
docutils==0.16
dyn==1.8.1
edgegrid-python==1.1.1
fqdn==1.5.0
futures==3.2.0; python_version < '3.2'
google-cloud-core==1.3.0
google-cloud-core==1.4.1
google-cloud-dns==0.32.0
ipaddress==1.0.23; python_version < '3.3'
jmespath==0.10.0
@ -17,7 +18,7 @@ natsort==6.2.1
ns1-python==0.16.0
ovh==0.5.0
pycountry-convert==0.7.2
pycountry==19.8.18
pycountry==20.7.3
python-dateutil==2.8.1
requests==2.24.0
s3transfer==0.3.3


+ 1
- 1
script/coverage View File

@ -27,7 +27,7 @@ export DYN_USERNAME=
export GOOGLE_APPLICATION_CREDENTIALS=
# 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"
exit 1
}


+ 1
- 1
setup.py View File

@ -81,6 +81,6 @@ setup(
long_description_content_type='text/markdown',
name='octodns',
packages=find_packages(),
url='https://github.com/github/octodns',
url='https://github.com/octodns/octodns',
version=octodns.__VERSION__,
)

+ 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
type: CNAME
value: unit.tests.
dname:
ttl: 300
type: DNAME
value: unit.tests.
excluded:
octodns:
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": [],
"pools": [],
"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,
"type": "A",


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

@ -320,20 +320,6 @@
"name": "",
"value": "aname.unit.tests.",
"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"
}, {
"failover": false,


+ 274
- 0
tests/fixtures/easydns-records.json View File

@ -0,0 +1,274 @@
{
"tm": 1000000000,
"data": [
{
"id": "12340001",
"domain": "unit.tests",
"host": "@",
"ttl": "3600",
"prio": "0",
"type": "SOA",
"rdata": "dns1.easydns.com. zone.easydns.com. 2020010101 3600 600 604800 0",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340002",
"domain": "unit.tests",
"host": "@",
"ttl": "300",
"prio": "0",
"type": "A",
"rdata": "1.2.3.4",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340003",
"domain": "unit.tests",
"host": "@",
"ttl": "300",
"prio": "0",
"type": "A",
"rdata": "1.2.3.5",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340004",
"domain": "unit.tests",
"host": "@",
"ttl": "0",
"prio": null,
"type": "NS",
"rdata": "6.2.3.4.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340005",
"domain": "unit.tests",
"host": "@",
"ttl": "0",
"prio": null,
"type": "NS",
"rdata": "7.2.3.4.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340006",
"domain": "unit.tests",
"host": "@",
"ttl": "3600",
"prio": "0",
"type": "CAA",
"rdata": "0 issue ca.unit.tests",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340007",
"domain": "unit.tests",
"host": "_srv._tcp",
"ttl": "600",
"prio": "12",
"type": "SRV",
"rdata": "12 20 30 foo-2.unit.tests.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340008",
"domain": "unit.tests",
"host": "_srv._tcp",
"ttl": "600",
"prio": "12",
"type": "SRV",
"rdata": "10 20 30 foo-1.unit.tests.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340009",
"domain": "unit.tests",
"host": "aaaa",
"ttl": "600",
"prio": "0",
"type": "AAAA",
"rdata": "2601:644:500:e210:62f8:1dff:feb8:947a",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340010",
"domain": "unit.tests",
"host": "cname",
"ttl": "300",
"prio": null,
"type": "CNAME",
"rdata": "@",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340012",
"domain": "unit.tests",
"host": "mx",
"ttl": "300",
"prio": "10",
"type": "MX",
"rdata": "smtp-4.unit.tests.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340013",
"domain": "unit.tests",
"host": "mx",
"ttl": "300",
"prio": "20",
"type": "MX",
"rdata": "smtp-2.unit.tests.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340014",
"domain": "unit.tests",
"host": "mx",
"ttl": "300",
"prio": "30",
"type": "MX",
"rdata": "smtp-3.unit.tests.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340015",
"domain": "unit.tests",
"host": "mx",
"ttl": "300",
"prio": "40",
"type": "MX",
"rdata": "smtp-1.unit.tests.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340016",
"domain": "unit.tests",
"host": "naptr",
"ttl": "600",
"prio": null,
"type": "NAPTR",
"rdata": "100 100 'U' 'SIP+D2U' '!^.*$!sip:info@bar.example.com!' .",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340017",
"domain": "unit.tests",
"host": "naptr",
"ttl": "600",
"prio": null,
"type": "NAPTR",
"rdata": "10 100 'S' 'SIP+D2U' '!^.*$!sip:info@bar.example.com!' .",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340018",
"domain": "unit.tests",
"host": "sub",
"ttl": "3600",
"prio": null,
"type": "NS",
"rdata": "6.2.3.4.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340019",
"domain": "unit.tests",
"host": "sub",
"ttl": "0",
"prio": null,
"type": "NS",
"rdata": "7.2.3.4.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340020",
"domain": "unit.tests",
"host": "www",
"ttl": "300",
"prio": "0",
"type": "A",
"rdata": "2.2.3.6",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340021",
"domain": "unit.tests",
"host": "www.sub",
"ttl": "300",
"prio": "0",
"type": "A",
"rdata": "2.2.3.6",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340022",
"domain": "unit.tests",
"host": "included",
"ttl": "3600",
"prio": null,
"type": "CNAME",
"rdata": "unit.tests.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340011",
"domain": "unit.tests",
"host": "txt",
"ttl": "600",
"prio": "0",
"type": "TXT",
"rdata": "Bah bah black sheep",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340023",
"domain": "unit.tests",
"host": "txt",
"ttl": "600",
"prio": "0",
"type": "TXT",
"rdata": "have you any wool.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340024",
"domain": "unit.tests",
"host": "txt",
"ttl": "600",
"prio": "0",
"type": "TXT",
"rdata": "v=DKIM1;k=rsa;s=email;h=sha256;p=A\/kinda+of\/long\/string+with+numb3rs",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}
],
"count": 24,
"total": 24,
"start": 0,
"max": 1000,
"status": 200
}

+ 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"
}

+ 1
- 1
tests/fixtures/ultra-records-page-1.json View File

@ -87,7 +87,7 @@
}
],
"resultInfo": {
"totalCount": 12,
"totalCount": 13,
"offset": 0,
"returnedCount": 10
}

+ 10
- 2
tests/fixtures/ultra-records-page-2.json View File

@ -24,11 +24,19 @@
"order": "FIXED",
"description": "octodns1.test."
}
},
{
"ownerName": "octodns1.test.",
"rrtype": "APEXALIAS (65282)",
"ttl": 3600,
"rdata": [
"www.octodns1.test."
]
}
],
"resultInfo": {
"totalCount": 12,
"totalCount": 13,
"offset": 10,
"returnedCount": 2
"returnedCount": 3
}
}

+ 1
- 1
tests/fixtures/ultra-zones-page-1.json View File

@ -19,7 +19,7 @@
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"resourceRecordCount": 6,
"lastModifiedDateTime": "2020-06-19T01:05Z"
}
},


+ 77
- 7
tests/test_octodns_manager.py View File

@ -118,12 +118,12 @@ class TestManager(TestCase):
environ['YAML_TMP_DIR'] = tmpdir.dirname
tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False)
self.assertEquals(21, tc)
self.assertEquals(22, tc)
# try with just one of the zones
tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False, eligible_zones=['unit.tests.'])
self.assertEquals(15, tc)
self.assertEquals(16, tc)
# the subzone, with 2 targets
tc = Manager(get_config_filename('simple.yaml')) \
@ -138,18 +138,26 @@ class TestManager(TestCase):
# Again with force
tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False, force=True)
self.assertEquals(21, tc)
self.assertEquals(22, tc)
# Again with max_workers = 1
tc = Manager(get_config_filename('simple.yaml'), max_workers=1) \
.sync(dry_run=False, force=True)
self.assertEquals(21, tc)
self.assertEquals(22, tc)
# Include meta
tc = Manager(get_config_filename('simple.yaml'), max_workers=1,
include_meta=True) \
.sync(dry_run=False, force=True)
self.assertEquals(25, tc)
self.assertEquals(26, tc)
def test_eligible_sources(self):
with TemporaryDirectory() as tmpdir:
environ['YAML_TMP_DIR'] = tmpdir.dirname
# Only allow a target that doesn't exist
tc = Manager(get_config_filename('simple.yaml')) \
.sync(eligible_sources=['foo'])
self.assertEquals(0, tc)
def test_eligible_targets(self):
with TemporaryDirectory() as tmpdir:
@ -159,6 +167,38 @@ class TestManager(TestCase):
.sync(eligible_targets=['foo'])
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))
# Sync an alias without the zone it refers to
with self.assertRaises(ManagerException) as ctx:
tc = Manager(get_config_filename('simple-alias-zone.yaml')) \
.sync(eligible_zones=["alias.tests."])
self.assertEquals('Zone alias.tests. cannot be sync without zone '
'unit.tests. sinced it is aliased',
text_type(ctx.exception))
def test_compare(self):
with TemporaryDirectory() as tmpdir:
environ['YAML_TMP_DIR'] = tmpdir.dirname
@ -175,13 +215,13 @@ class TestManager(TestCase):
fh.write('---\n{}')
changes = manager.compare(['in'], ['dump'], 'unit.tests.')
self.assertEquals(15, len(changes))
self.assertEquals(16, len(changes))
# Compound sources with varying support
changes = manager.compare(['in', 'nosshfp'],
['dump'],
'unit.tests.')
self.assertEquals(14, len(changes))
self.assertEquals(15, len(changes))
with self.assertRaises(ManagerException) as ctx:
manager.compare(['nope'], ['dump'], 'unit.tests.')
@ -278,6 +318,36 @@ class TestManager(TestCase):
.validate_configs()
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):
with TemporaryDirectory() as tmpdir:
environ['YAML_TMP_DIR'] = tmpdir.dirname


+ 1
- 7
tests/test_octodns_provider_azuredns.py View File

@ -389,9 +389,6 @@ class TestAzureDnsProvider(TestCase):
recordSet = RecordSet(cname_record=cname1)
recordSet.name, recordSet.ttl, recordSet.type = 'cname1', 5, 'CNAME'
rs.append(recordSet)
recordSet = RecordSet(cname_record=None)
recordSet.name, recordSet.ttl, recordSet.type = 'cname2', 6, 'CNAME'
rs.append(recordSet)
recordSet = RecordSet(mx_records=[MxRecord(preference=10,
exchange='mx1.unit.test.')])
recordSet.name, recordSet.ttl, recordSet.type = 'mx1', 7, 'MX'
@ -413,9 +410,6 @@ class TestAzureDnsProvider(TestCase):
recordSet = RecordSet(ptr_records=[ptr1])
recordSet.name, recordSet.ttl, recordSet.type = 'ptr1', 11, 'PTR'
rs.append(recordSet)
recordSet = RecordSet(ptr_records=[PtrRecord(ptrdname=None)])
recordSet.name, recordSet.ttl, recordSet.type = 'ptr2', 12, 'PTR'
rs.append(recordSet)
recordSet = RecordSet(srv_records=[SrvRecord(priority=1,
weight=2,
port=3,
@ -449,7 +443,7 @@ class TestAzureDnsProvider(TestCase):
exists = provider.populate(zone)
self.assertTrue(exists)
self.assertEquals(len(zone.records), 18)
self.assertEquals(len(zone.records), 16)
def test_populate_zone(self):
provider = self._get_provider()


+ 4
- 10
tests/test_octodns_provider_constellix.py View File

@ -42,12 +42,6 @@ class TestConstellixProvider(TestCase):
'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):
if record.name == 'sub' and record._type == 'NS':
expected._remove_record(record)
@ -107,14 +101,14 @@ class TestConstellixProvider(TestCase):
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(15, len(zone.records))
self.assertEquals(14, len(zone.records))
changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEquals(15, len(again.records))
self.assertEquals(14, len(again.records))
# bust the cache
del provider._zone_records[zone.name]
@ -138,7 +132,7 @@ class TestConstellixProvider(TestCase):
plan = provider.plan(self.expected)
# 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, 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()


+ 1
- 1
tests/test_octodns_provider_digitalocean.py View File

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


+ 448
- 0
tests/test_octodns_provider_easydns.py View File

@ -0,0 +1,448 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
import json
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.easydns import EasyDNSClientNotFound, \
EasyDNSProvider
from octodns.provider.yaml import YamlProvider
from octodns.zone import Zone
class TestEasyDNSProvider(TestCase):
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
def test_populate(self):
provider = EasyDNSProvider('test', 'token', 'apikey')
# Bad auth
with requests_mock() as mock:
mock.get(ANY, status_code=401,
text='{"id":"unauthorized",'
'"message":"Unable to authenticate you."}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('Unauthorized', text_type(ctx.exception))
# Bad request
with requests_mock() as mock:
mock.get(ANY, status_code=400,
text='{"id":"invalid",'
'"message":"Bad request"}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('Bad request', 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)
# Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"id":"not_found","message":"The resource you '
'were accessing could not be found."}')
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(set(), zone.records)
# No diffs == no changes
with requests_mock() as mock:
base = 'https://rest.easydns.net/zones/records/'
with open('tests/fixtures/easydns-records.json') as fh:
mock.get('{}{}'.format(base, 'parsed/unit.tests'),
text=fh.read())
with open('tests/fixtures/easydns-records.json') as fh:
mock.get('{}{}'.format(base, 'all/unit.tests'),
text=fh.read())
provider.populate(zone)
self.assertEquals(13, len(zone.records))
changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEquals(13, len(again.records))
# bust the cache
del provider._zone_records[zone.name]
def test_domain(self):
provider = EasyDNSProvider('test', 'token', 'apikey')
with requests_mock() as mock:
base = 'https://rest.easydns.net/'
mock.get('{}{}'.format(base, 'domain/unit.tests'), status_code=400,
text='{"id":"not_found","message":"The resource you '
'were accessing could not be found."}')
with self.assertRaises(Exception) as ctx:
provider._client.domain('unit.tests')
self.assertEquals('Not Found', text_type(ctx.exception))
def test_apply_not_found(self):
provider = EasyDNSProvider('test', 'token', 'apikey')
wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, 'test1', {
"name": "test1",
"ttl": 300,
"type": "A",
"value": "1.2.3.4",
}))
with requests_mock() as mock:
base = 'https://rest.easydns.net/'
mock.get('{}{}'.format(base, 'domain/unit.tests'), status_code=404,
text='{"id":"not_found","message":"The resource you '
'were accessing could not be found."}')
mock.put('{}{}'.format(base, 'domains/add/unit.tests'),
status_code=200,
text='{"id":"OK","message":"Zone created."}')
mock.get('{}{}'.format(base, 'zones/records/parsed/unit.tests'),
status_code=404,
text='{"id":"not_found","message":"The resource you '
'were accessing could not be found."}')
mock.get('{}{}'.format(base, 'zones/records/all/unit.tests'),
status_code=404,
text='{"id":"not_found","message":"The resource you '
'were accessing could not be found."}')
plan = provider.plan(wanted)
self.assertFalse(plan.exists)
self.assertEquals(1, len(plan.changes))
with self.assertRaises(Exception) as ctx:
provider.apply(plan)
self.assertEquals('Not Found', text_type(ctx.exception))
def test_domain_create(self):
provider = EasyDNSProvider('test', 'token', 'apikey')
domain_after_creation = {
"tm": 1000000000,
"data": [{
"id": "12341001",
"domain": "unit.tests",
"host": "@",
"ttl": "0",
"prio": "0",
"type": "SOA",
"rdata": "dns1.easydns.com. zone.easydns.com. "
"2020010101 3600 600 604800 0",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}, {
"id": "12341002",
"domain": "unit.tests",
"host": "@",
"ttl": "0",
"prio": "0",
"type": "NS",
"rdata": "LOCAL.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}, {
"id": "12341003",
"domain": "unit.tests",
"host": "@",
"ttl": "0",
"prio": "0",
"type": "MX",
"rdata": "LOCAL.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}],
"count": 3,
"total": 3,
"start": 0,
"max": 1000,
"status": 200
}
with requests_mock() as mock:
base = 'https://rest.easydns.net/'
mock.put('{}{}'.format(base, 'domains/add/unit.tests'),
status_code=201, text='{"id":"OK"}')
mock.get('{}{}'.format(base, 'zones/records/all/unit.tests'),
text=json.dumps(domain_after_creation))
mock.delete(ANY, text='{"id":"OK"}')
provider._client.domain_create('unit.tests')
def test_caa(self):
provider = EasyDNSProvider('test', 'token', 'apikey')
# Invalid rdata records
caa_record_invalid = [{
"domain": "unit.tests",
"host": "@",
"ttl": "3600",
"prio": "0",
"type": "CAA",
"rdata": "0",
}]
# Valid rdata records
caa_record_valid = [{
"domain": "unit.tests",
"host": "@",
"ttl": "3600",
"prio": "0",
"type": "CAA",
"rdata": "0 issue ca.unit.tests",
}]
provider._data_for_CAA('CAA', caa_record_invalid)
provider._data_for_CAA('CAA', caa_record_valid)
def test_naptr(self):
provider = EasyDNSProvider('test', 'token', 'apikey')
# Invalid rdata records
naptr_record_invalid = [{
"domain": "unit.tests",
"host": "naptr",
"ttl": "600",
"prio": "10",
"type": "NAPTR",
"rdata": "100",
}]
# Valid rdata records
naptr_record_valid = [{
"domain": "unit.tests",
"host": "naptr",
"ttl": "600",
"prio": "10",
"type": "NAPTR",
"rdata": "10 10 'U' 'SIP+D2U' '!^.*$!sip:info@bar.example.com!' .",
}]
provider._data_for_NAPTR('NAPTR', naptr_record_invalid)
provider._data_for_NAPTR('NAPTR', naptr_record_valid)
def test_srv(self):
provider = EasyDNSProvider('test', 'token', 'apikey')
# Invalid rdata records
srv_invalid = [{
"domain": "unit.tests",
"host": "_srv._tcp",
"ttl": "600",
"type": "SRV",
"rdata": "",
}]
srv_invalid2 = [{
"domain": "unit.tests",
"host": "_srv._tcp",
"ttl": "600",
"type": "SRV",
"rdata": "11",
}]
srv_invalid3 = [{
"domain": "unit.tests",
"host": "_srv._tcp",
"ttl": "600",
"type": "SRV",
"rdata": "12 30",
}]
srv_invalid4 = [{
"domain": "unit.tests",
"host": "_srv._tcp",
"ttl": "600",
"type": "SRV",
"rdata": "13 40 1234",
}]
# Valid rdata
srv_valid = [{
"domain": "unit.tests",
"host": "_srv._tcp",
"ttl": "600",
"type": "SRV",
"rdata": "100 20 5678 foo-2.unit.tests.",
}]
srv_invalid_content = provider._data_for_SRV('SRV', srv_invalid)
srv_invalid_content2 = provider._data_for_SRV('SRV', srv_invalid2)
srv_invalid_content3 = provider._data_for_SRV('SRV', srv_invalid3)
srv_invalid_content4 = provider._data_for_SRV('SRV', srv_invalid4)
srv_valid_content = provider._data_for_SRV('SRV', srv_valid)
self.assertEqual(srv_valid_content['values'][0]['priority'], 100)
self.assertEqual(srv_invalid_content['values'][0]['priority'], 0)
self.assertEqual(srv_invalid_content2['values'][0]['priority'], 11)
self.assertEqual(srv_invalid_content3['values'][0]['priority'], 12)
self.assertEqual(srv_invalid_content4['values'][0]['priority'], 13)
self.assertEqual(srv_valid_content['values'][0]['weight'], 20)
self.assertEqual(srv_invalid_content['values'][0]['weight'], 0)
self.assertEqual(srv_invalid_content2['values'][0]['weight'], 0)
self.assertEqual(srv_invalid_content3['values'][0]['weight'], 30)
self.assertEqual(srv_invalid_content4['values'][0]['weight'], 40)
self.assertEqual(srv_valid_content['values'][0]['port'], 5678)
self.assertEqual(srv_invalid_content['values'][0]['port'], 0)
self.assertEqual(srv_invalid_content2['values'][0]['port'], 0)
self.assertEqual(srv_invalid_content3['values'][0]['port'], 0)
self.assertEqual(srv_invalid_content4['values'][0]['port'], 1234)
self.assertEqual(srv_valid_content['values'][0]['target'],
'foo-2.unit.tests.')
self.assertEqual(srv_invalid_content['values'][0]['target'], '')
self.assertEqual(srv_invalid_content2['values'][0]['target'], '')
self.assertEqual(srv_invalid_content3['values'][0]['target'], '')
self.assertEqual(srv_invalid_content4['values'][0]['target'], '')
def test_apply(self):
provider = EasyDNSProvider('test', 'token', 'apikey')
resp = Mock()
resp.json = Mock()
provider._client._request = Mock(return_value=resp)
domain_after_creation = {
"tm": 1000000000,
"data": [{
"id": "12341001",
"domain": "unit.tests",
"host": "@",
"ttl": "0",
"prio": "0",
"type": "SOA",
"rdata": "dns1.easydns.com. zone.easydns.com. 2020010101"
" 3600 600 604800 0",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}, {
"id": "12341002",
"domain": "unit.tests",
"host": "@",
"ttl": "0",
"prio": "0",
"type": "NS",
"rdata": "LOCAL.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}, {
"id": "12341003",
"domain": "unit.tests",
"host": "@",
"ttl": "0",
"prio": "0",
"type": "MX",
"rdata": "LOCAL.",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}],
"count": 3,
"total": 3,
"start": 0,
"max": 1000,
"status": 200
}
# non-existent domain, create everything
resp.json.side_effect = [
EasyDNSClientNotFound, # no zone in populate
domain_after_creation
]
plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 7
self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan))
self.assertFalse(plan.exists)
self.assertEquals(23, provider._client._request.call_count)
provider._client._request.reset_mock()
# delete 1 and update 1
provider._client.records = Mock(return_value=[
{
"id": "12342001",
"domain": "unit.tests",
"host": "www",
"ttl": "300",
"prio": "0",
"type": "A",
"rdata": "2.2.3.9",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}, {
"id": "12342002",
"domain": "unit.tests",
"host": "www",
"ttl": "300",
"prio": "0",
"type": "A",
"rdata": "2.2.3.8",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}, {
"id": "12342003",
"domain": "unit.tests",
"host": "test1",
"ttl": "3600",
"prio": "0",
"type": "A",
"rdata": "1.2.3.4",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
}
])
# Domain exists, we don't care about return
resp.json.side_effect = ['{}']
wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, 'test1', {
"name": "test1",
"ttl": 300,
"type": "A",
"value": "1.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 delete for the 2 parts of the other
provider._client._request.assert_has_calls([
call('PUT', '/zones/records/add/unit.tests/A', data={
'rdata': '1.2.3.4',
'name': 'test1',
'ttl': 300,
'type': 'A',
'host': 'test1',
}),
call('DELETE', '/zones/records/unit.tests/12342001'),
call('DELETE', '/zones/records/unit.tests/12342002'),
call('DELETE', '/zones/records/unit.tests/12342003')
], any_order=True)

+ 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 registered 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):
zone = Zone('unit.tests.', [])
zone.add_record(Record.new(zone, 'prawf-alias', {
zone.add_record(Record.new(zone, '', {
'ttl': 60,
'type': 'ALIAS',
'value': 'alias.unit.tests.',
@ -228,7 +228,7 @@ class TestMythicBeastsProvider(TestCase):
)
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 alias2.unit.tests.',
'ADD prawf-a.unit.tests 60 A 1.2.3.4',


+ 16
- 2
tests/test_octodns_provider_powerdns.py View File

@ -82,6 +82,20 @@ class TestPowerDnsProvider(TestCase):
provider._powerdns_version = None
self.assertNotEquals(provider.powerdns_version, [4, 1, 10])
# Test version detection with pre-releases
with requests_mock() as mock:
# Reset version, so detection will try again
provider._powerdns_version = None
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200, json={'version': "4.4.0-alpha1"})
self.assertEquals(provider.powerdns_version, [4, 4, 0])
provider._powerdns_version = None
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200,
json={'version': "4.5.0-alpha0.435.master.gcb114252b"})
self.assertEquals(provider.powerdns_version, [4, 5, 0])
def test_provider_version_config(self):
provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=['8.8.8.8.',
@ -171,7 +185,7 @@ class TestPowerDnsProvider(TestCase):
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
expected_n = len(expected.records) - 2
expected_n = len(expected.records) - 3
self.assertEquals(16, expected_n)
# No diffs == no changes
@ -277,7 +291,7 @@ class TestPowerDnsProvider(TestCase):
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
self.assertEquals(18, len(expected.records))
self.assertEquals(19, len(expected.records))
# A small change to a single record
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
source.populate(zone)
self.assertEquals(18, len(zone.records))
self.assertEquals(19, len(zone.records))
source.populate(dynamic_zone)
self.assertEquals(5, len(dynamic_zone.records))
@ -58,12 +58,12 @@ class TestYamlProvider(TestCase):
# We add everything
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)]))
self.assertFalse(isfile(yaml_file))
# Now actually do it
self.assertEquals(15, target.apply(plan))
self.assertEquals(16, target.apply(plan))
self.assertTrue(isfile(yaml_file))
# Dynamic plan
@ -87,7 +87,7 @@ class TestYamlProvider(TestCase):
# A 2nd sync should still create everything
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)]))
with open(yaml_file) as fh:
@ -109,6 +109,7 @@ class TestYamlProvider(TestCase):
# these are stored as singular 'value'
self.assertTrue('value' in data.pop('aaaa'))
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('ptr'))
self.assertTrue('value' in data.pop('spf'))
@ -237,7 +238,7 @@ class TestSplitYamlProvider(TestCase):
# without it we see everything
source.populate(zone)
self.assertEquals(18, len(zone.records))
self.assertEquals(19, len(zone.records))
source.populate(dynamic_zone)
self.assertEquals(5, len(dynamic_zone.records))
@ -251,12 +252,12 @@ class TestSplitYamlProvider(TestCase):
# We add everything
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)]))
self.assertFalse(isdir(zone_dir))
# Now actually do it
self.assertEquals(15, target.apply(plan))
self.assertEquals(16, target.apply(plan))
# Dynamic plan
plan = target.plan(dynamic_zone)
@ -279,7 +280,7 @@ class TestSplitYamlProvider(TestCase):
# A 2nd sync should still create everything
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)]))
yaml_file = join(zone_dir, '$unit.tests.yaml')
@ -302,8 +303,8 @@ class TestSplitYamlProvider(TestCase):
self.assertTrue('values' in data.pop(record_name))
# 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))
self.assertTrue(isfile(yaml_file))
with open(yaml_file) as fh:
@ -387,7 +388,7 @@ class TestOverridingYamlProvider(TestCase):
base.populate(zone)
got = {r.name: r for r in zone.records}
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)
# No added
self.assertFalse('added' in got)


+ 153
- 8
tests/test_octodns_record.py View File

@ -9,10 +9,10 @@ from six import text_type
from unittest import TestCase
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 helpers import DynamicProvider, GeoProvider, SimpleProvider
@ -55,6 +55,19 @@ class TestRecord(TestCase):
})
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):
upper_record = PtrRecord(self.zone, 'PtrUppwerValue', {
'ttl': 30,
@ -362,6 +375,10 @@ class TestRecord(TestCase):
self.assertSingleValue(CnameRecord, 'target.foo.com.',
'other.foo.com.')
def test_dname(self):
self.assertSingleValue(DnameRecord, 'target.foo.com.',
'other.foo.com.')
def test_mx(self):
a_values = [{
'preference': 10,
@ -796,6 +813,39 @@ class TestRecord(TestCase):
})
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):
existing = Record.new(self.zone, 'txt', {
'ttl': 44,
@ -1265,7 +1315,7 @@ class TestRecordValidation(TestCase):
self.assertTrue(reason.endswith('.unit.tests." is too long at 254'
' chars, max is 253'))
# label length, DNS defins max as 63
# label length, DNS defines max as 63
with self.assertRaises(ValidationError) as ctx:
# The . will put this over the edge
name = 'x' * 64
@ -1275,10 +1325,30 @@ class TestRecordValidation(TestCase):
'value': '1.2.3.4',
})
reason = ctx.exception.reasons[0]
self.assertTrue(reason.startswith('invalid name, "xxxx'))
self.assertTrue(reason.startswith('invalid label, "xxxx'))
self.assertTrue(reason.endswith('xxx" is too long at 64'
' chars, max is 63'))
with self.assertRaises(ValidationError) as ctx:
name = 'foo.' + 'x' * 64 + '.bar'
Record.new(self.zone, name, {
'ttl': 300,
'type': 'A',
'value': '1.2.3.4',
})
reason = ctx.exception.reasons[0]
self.assertTrue(reason.startswith('invalid label, "xxxx'))
self.assertTrue(reason.endswith('xxx" is too long at 64'
' chars, max is 63'))
# should not raise with dots
name = 'xxxxxxxx.' * 10
Record.new(self.zone, name, {
'ttl': 300,
'type': 'A',
'value': '1.2.3.4',
})
# no ttl
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
@ -1693,6 +1763,16 @@ class TestRecordValidation(TestCase):
'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
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
@ -1703,7 +1783,7 @@ class TestRecordValidation(TestCase):
# missing value
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'www', {
Record.new(self.zone, '', {
'type': 'ALIAS',
'ttl': 600,
'value': None
@ -1712,13 +1792,23 @@ class TestRecordValidation(TestCase):
# empty value
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'www', {
Record.new(self.zone, '', {
'type': 'ALIAS',
'ttl': 600,
'value': ''
})
self.assertEquals(['empty value'], ctx.exception.reasons)
# not a valid FQDN
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'ALIAS',
'ttl': 600,
'value': '__.',
})
self.assertEquals(['ALIAS value "__." is not a valid FQDN'],
ctx.exception.reasons)
# missing trailing .
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
@ -1815,6 +1905,16 @@ class TestRecordValidation(TestCase):
})
self.assertEquals(['root CNAME not allowed'], ctx.exception.reasons)
# not a valid FQDN
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'www', {
'type': 'CNAME',
'ttl': 600,
'value': '___.',
})
self.assertEquals(['CNAME value "___." is not a valid FQDN'],
ctx.exception.reasons)
# missing trailing .
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'www', {
@ -1825,6 +1925,41 @@ class TestRecordValidation(TestCase):
self.assertEquals(['CNAME value "foo.bar.com" missing trailing .'],
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.',
})
# not a valid FQDN
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'www', {
'type': 'DNAME',
'ttl': 600,
'value': '.',
})
self.assertEquals(['DNAME value "." is not a valid FQDN'],
ctx.exception.reasons)
# 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):
# doesn't blow up
Record.new(self.zone, '', {
@ -1998,6 +2133,16 @@ class TestRecordValidation(TestCase):
})
self.assertEquals(['missing value'], ctx.exception.reasons)
# not a valid FQDN
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'PTR',
'ttl': 600,
'value': '_.',
})
self.assertEquals(['PTR value "_." is not a valid FQDN'],
ctx.exception.reasons)
# no trailing .
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {


+ 3
- 3
tests/test_octodns_source_axfr.py View File

@ -34,7 +34,7 @@ class TestAxfrSource(TestCase):
]
self.source.populate(got)
self.assertEquals(11, len(got.records))
self.assertEquals(12, len(got.records))
with self.assertRaises(AxfrSourceZoneTransferFailed) as ctx:
zone = Zone('unit.tests.', [])
@ -50,12 +50,12 @@ class TestZoneFileSource(TestCase):
# Valid zone file in directory
valid = Zone('unit.tests.', [])
self.source.populate(valid)
self.assertEquals(11, len(valid.records))
self.assertEquals(12, len(valid.records))
# 2nd populate does not read file again
again = Zone('unit.tests.', [])
self.source.populate(again)
self.assertEquals(11, len(again.records))
self.assertEquals(12, len(again.records))
# bust the cache
del self.source._zone_records[valid.name]


+ 4
- 0
tests/zones/unit.tests. View File

@ -13,6 +13,10 @@ $ORIGIN unit.tests.
under 3600 IN NS ns1.unit.tests.
under 3600 IN NS ns2.unit.tests.
; CAA Records
caa 1800 IN CAA 0 issue "ca.unit.tests"
caa 1800 IN CAA 0 iodef "mailto:admin@unit.tests"
; SRV Records
_srv._tcp 600 IN SRV 10 20 30 foo-1.unit.tests.
_srv._tcp 600 IN SRV 10 20 30 foo-2.unit.tests.


Loading…
Cancel
Save