Browse Source

Merge branch 'master' into processors

pull/637/head
Ross McFarland 5 years ago
committed by GitHub
parent
commit
236615fdd5
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 1836 additions and 206 deletions
  1. +6
    -6
      .git_hooks_pre-commit
  2. +6
    -2
      .github/workflows/main.yml
  3. +2
    -1
      .gitignore
  4. +3
    -3
      CHANGELOG.md
  5. +1
    -1
      CONTRIBUTING.md
  6. +33
    -7
      README.md
  7. +16
    -0
      docs/records.md
  8. +11
    -5
      octodns/manager.py
  9. +97
    -31
      octodns/provider/azuredns.py
  10. +4
    -1
      octodns/provider/base.py
  11. +79
    -5
      octodns/provider/cloudflare.py
  12. +5
    -1
      octodns/provider/digitalocean.py
  13. +38
    -2
      octodns/provider/dnsimple.py
  14. +24
    -0
      octodns/provider/dnsmadeeasy.py
  15. +1
    -1
      octodns/provider/gandi.py
  16. +57
    -2
      octodns/provider/powerdns.py
  17. +7
    -1
      octodns/provider/ultra.py
  18. +5
    -3
      octodns/provider/yaml.py
  19. +193
    -1
      octodns/record/__init__.py
  20. +42
    -7
      octodns/source/axfr.py
  21. +1
    -1
      requirements-dev.txt
  22. +2
    -1
      setup.py
  23. +3
    -0
      tests/config/simple-split.yaml
  24. +0
    -0
      tests/config/split/dynamic.tests.tst/a.yaml
  25. +0
    -0
      tests/config/split/dynamic.tests.tst/aaaa.yaml
  26. +0
    -0
      tests/config/split/dynamic.tests.tst/cname.yaml
  27. +0
    -0
      tests/config/split/dynamic.tests.tst/real-ish-a.yaml
  28. +0
    -0
      tests/config/split/dynamic.tests.tst/simple-weighted.yaml
  29. +0
    -0
      tests/config/split/empty.tst/.gitkeep
  30. +0
    -0
      tests/config/split/subzone.unit.tests.tst/12.yaml
  31. +0
    -0
      tests/config/split/subzone.unit.tests.tst/2.yaml
  32. +0
    -0
      tests/config/split/subzone.unit.tests.tst/test.yaml
  33. +0
    -0
      tests/config/split/unit.tests.tst/$unit.tests.yaml
  34. +0
    -0
      tests/config/split/unit.tests.tst/_srv._tcp.yaml
  35. +0
    -0
      tests/config/split/unit.tests.tst/aaaa.yaml
  36. +0
    -0
      tests/config/split/unit.tests.tst/cname.yaml
  37. +0
    -0
      tests/config/split/unit.tests.tst/dname.yaml
  38. +0
    -0
      tests/config/split/unit.tests.tst/excluded.yaml
  39. +0
    -0
      tests/config/split/unit.tests.tst/ignored.yaml
  40. +0
    -0
      tests/config/split/unit.tests.tst/included.yaml
  41. +0
    -0
      tests/config/split/unit.tests.tst/mx.yaml
  42. +0
    -0
      tests/config/split/unit.tests.tst/naptr.yaml
  43. +0
    -0
      tests/config/split/unit.tests.tst/ptr.yaml
  44. +0
    -0
      tests/config/split/unit.tests.tst/spf.yaml
  45. +0
    -0
      tests/config/split/unit.tests.tst/sub.yaml
  46. +0
    -0
      tests/config/split/unit.tests.tst/txt.yaml
  47. +0
    -0
      tests/config/split/unit.tests.tst/www.sub.yaml
  48. +0
    -0
      tests/config/split/unit.tests.tst/www.yaml
  49. +0
    -0
      tests/config/split/unordered.tst/abc.yaml
  50. +0
    -0
      tests/config/split/unordered.tst/xyz.yaml
  51. +44
    -0
      tests/config/unit.tests.yaml
  52. +16
    -16
      tests/fixtures/cloudflare-dns_records-page-2.json
  53. +128
    -0
      tests/fixtures/cloudflare-dns_records-page-3.json
  54. +56
    -0
      tests/fixtures/constellix-records.json
  55. +22
    -0
      tests/fixtures/digitalocean-page-2.json
  56. +24
    -2
      tests/fixtures/easydns-records.json
  57. +18
    -2
      tests/fixtures/edgedns-records.json
  58. +18
    -0
      tests/fixtures/gandi-no-changes.json
  59. +2
    -0
      tests/fixtures/mythicbeasts-list.txt
  60. +40
    -0
      tests/fixtures/powerdns-full-data.json
  61. +1
    -1
      tests/fixtures/ultra-records-page-1.json
  62. +10
    -2
      tests/fixtures/ultra-records-page-2.json
  63. +1
    -1
      tests/fixtures/ultra-zones-page-1.json
  64. +17
    -9
      tests/test_octodns_manager.py
  65. +91
    -18
      tests/test_octodns_provider_azuredns.py
  66. +78
    -11
      tests/test_octodns_provider_cloudflare.py
  67. +4
    -4
      tests/test_octodns_provider_constellix.py
  68. +22
    -4
      tests/test_octodns_provider_digitalocean.py
  69. +1
    -1
      tests/test_octodns_provider_dnsimple.py
  70. +1
    -1
      tests/test_octodns_provider_dnsmadeeasy.py
  71. +4
    -4
      tests/test_octodns_provider_easydns.py
  72. +5
    -5
      tests/test_octodns_provider_edgedns.py
  73. +21
    -5
      tests/test_octodns_provider_gandi.py
  74. +4
    -4
      tests/test_octodns_provider_mythicbeasts.py
  75. +3
    -3
      tests/test_octodns_provider_powerdns.py
  76. +2
    -2
      tests/test_octodns_provider_transip.py
  77. +4
    -4
      tests/test_octodns_provider_ultra.py
  78. +26
    -16
      tests/test_octodns_provider_yaml.py
  79. +484
    -4
      tests/test_octodns_record.py
  80. +34
    -5
      tests/test_octodns_source_axfr.py
  81. +12
    -0
      tests/zones/ext.unit.tests.extension
  82. +0
    -0
      tests/zones/invalid.records.tst
  83. +0
    -0
      tests/zones/invalid.zone.tst
  84. +7
    -0
      tests/zones/unit.tests.tst

+ 6
- 6
.git_hooks_pre-commit View File

@ -2,10 +2,10 @@
set -e set -e
HOOKS=`dirname $0`
GIT=`dirname $HOOKS`
ROOT=`dirname $GIT`
HOOKS=$(dirname "$0")
GIT=$(dirname "$HOOKS")
ROOT=$(dirname "$GIT")
. $ROOT/env/bin/activate
$ROOT/script/lint
$ROOT/script/coverage
. "$ROOT/env/bin/activate"
"$ROOT/script/lint"
"$ROOT/script/coverage"

+ 6
- 2
.github/workflows/main.yml View File

@ -1,12 +1,16 @@
name: OctoDNS name: OctoDNS
on: [pull_request]
on:
pull_request:
paths-ignore:
- '**.md'
jobs: jobs:
ci: ci:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [2.7, 3.7, 3.9]
# Tested versions based on dates in https://devguide.python.org/devcycle/#end-of-life-branches,
python-version: [3.6, 3.7, 3.8, 3.9]
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Setup python - name: Setup python


+ 2
- 1
.gitignore View File

@ -5,8 +5,8 @@
*.pyc *.pyc
.coverage .coverage
.env .env
/config/
/build/ /build/
/config/
coverage.xml coverage.xml
dist/ dist/
env/ env/
@ -14,4 +14,5 @@ htmlcov/
nosetests.xml nosetests.xml
octodns.egg-info/ octodns.egg-info/
output/ output/
tests/zones/unit.tests.
tmp/ tmp/

+ 3
- 3
CHANGELOG.md View File

@ -55,7 +55,7 @@
* Explicit ordering of changes by (name, type) to address inconsistent * Explicit ordering of changes by (name, type) to address inconsistent
ordering for a number of providers that just convert changes into API 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 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 * 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 random in Python 3. This has been addressed and may result in value
reordering on next plan, no actual changes in behavior should occur. reordering on next plan, no actual changes in behavior should occur.
@ -152,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 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 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 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, * 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 * SSHFP algorithm type 4
* NS1 and DNSimple support skipping unsupported record types * NS1 and DNSimple support skipping unsupported record types
* Revert back to old style setup.py & requirements.txt, setup.cfg was * 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. 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 ## How to contribute


+ 33
- 7
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 ## DNS as code - Tools for managing DNS across multiple providers
@ -28,6 +28,7 @@ It is similar to [Netflix/denominator](https://github.com/Netflix/denominator).
- [Dynamic sources](#dynamic-sources) - [Dynamic sources](#dynamic-sources)
- [Contributing](#contributing) - [Contributing](#contributing)
- [Getting help](#getting-help) - [Getting help](#getting-help)
- [Related Projects & Resources](#related-projects--resources)
- [License](#license) - [License](#license)
- [Authors](#authors) - [Authors](#authors)
@ -102,8 +103,8 @@ Now that we have something to tell OctoDNS about our providers & zones we need t
ttl: 60 ttl: 60
type: A type: A
values: values:
- 1.2.3.4
- 1.2.3.5
- 1.2.3.4
- 1.2.3.5
``` ```
Further information can be found in [Records Documentation](/docs/records.md). Further information can be found in [Records Documentation](/docs/records.md).
@ -185,7 +186,7 @@ The above command pulled the existing data out of Route53 and placed the results
|--|--|--|--|--| |--|--|--|--|--|
| [AzureProvider](/octodns/provider/azuredns.py) | azure-mgmt-dns | A, AAAA, CAA, CNAME, MX, NS, PTR, SRV, TXT | No | | | [AzureProvider](/octodns/provider/azuredns.py) | azure-mgmt-dns | A, AAAA, CAA, CNAME, MX, NS, PTR, SRV, TXT | No | |
| [Akamai](/octodns/provider/edgedns.py) | edgegrid-python | A, AAAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT | No | | | [Akamai](/octodns/provider/edgedns.py) | edgegrid-python | A, AAAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT | No | |
| [CloudflareProvider](/octodns/provider/cloudflare.py) | | A, AAAA, ALIAS, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
| [CloudflareProvider](/octodns/provider/cloudflare.py) | | A, AAAA, ALIAS, CAA, CNAME, LOC, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
| [ConstellixProvider](/octodns/provider/constellix.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted | | [ConstellixProvider](/octodns/provider/constellix.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
| [DigitalOceanProvider](/octodns/provider/digitalocean.py) | | A, AAAA, CAA, CNAME, MX, NS, TXT, SRV | No | CAA tags restricted | | [DigitalOceanProvider](/octodns/provider/digitalocean.py) | | A, AAAA, CAA, CNAME, MX, NS, TXT, SRV | No | CAA tags restricted |
| [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted | | [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
@ -205,7 +206,7 @@ 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 | | | [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 | | | [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 | | | [UltraDns](/octodns/provider/ultra.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | |
| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CAA, CNAME, LOC, 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 | | [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 | | [TinyDnsFileSource](/octodns/source/tinydns.py) | | A, CNAME, MX, NS, PTR | No | read-only |
| [YamlProvider](/octodns/provider/yaml.py) | | All | Yes | config | | [YamlProvider](/octodns/provider/yaml.py) | | All | Yes | config |
@ -225,6 +226,8 @@ Most of the things included in OctoDNS are providers, the obvious difference bei
The `class` key in the providers config section can be used to point to arbitrary classes in the python path so internal or 3rd party providers can easily be included with no coordination beyond getting them into PYTHONPATH, most likely installed into the virtualenv with OctoDNS. The `class` key in the providers config section can be used to point to arbitrary classes in the python path so internal or 3rd party providers can easily be included with no coordination beyond getting them into PYTHONPATH, most likely installed into the virtualenv with OctoDNS.
For examples of building third-party sources and providers, see [Related Projects & Resources](#related-projects--resources).
## Other Uses ## Other Uses
### Syncing between providers ### Syncing between providers
@ -284,13 +287,36 @@ Please see our [contributing document](/CONTRIBUTING.md) if you would like to pa
## Getting help ## 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).
## Related Projects & Resources
- **GitHub Action:** [OctoDNS-Sync](https://github.com/marketplace/actions/octodns-sync)
- **Sample Implementations.** See how others are using it
- [`hackclub/dns`](https://github.com/hackclub/dns)
- [`kubernetes/k8s.io:/dns`](https://github.com/kubernetes/k8s.io/tree/master/dns)
- [`g0v-network/domains`](https://github.com/g0v-network/domains)
- [`jekyll/dns`](https://github.com/jekyll/dns)
- **Custom Sources & Providers.**
- [`octodns/octodns-ddns`](https://github.com/octodns/octodns-ddns): A simple Dynamic DNS source.
- [`doddo/octodns-lexicon`](https://github.com/doddo/octodns-lexicon): Use [Lexicon](https://github.com/AnalogJ/lexicon) providers as octoDNS providers.
- [`asyncon/octoblox`](https://github.com/asyncon/octoblox): [Infoblox](https://www.infoblox.com/) provider.
- [`sukiyaki/octodns-netbox`](https://github.com/sukiyaki/octodns-netbox): [NetBox](https://github.com/netbox-community/netbox) source.
- [`kompetenzbolzen/octodns-custom-provider`](https://github.com/kompetenzbolzen/octodns-custom-provider): zonefile provider & phpIPAM source.
- **Resources.**
- Article: [Visualising DNS records with Neo4j](https://medium.com/@costask/querying-and-visualising-octodns-records-with-neo4j-f4f72ab2d474) + code
- Video: [FOSDEM 2019 - DNS as code with octodns](https://archive.fosdem.org/2019/schedule/event/dns_octodns/)
- GitHub Blog: [Enabling DNS split authority with OctoDNS](https://github.blog/2017-04-27-enabling-split-authority-dns-with-octodns/)
- Tutorial: [How To Deploy and Manage Your DNS using OctoDNS on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-deploy-and-manage-your-dns-using-octodns-on-ubuntu-18-04)
- Cloudflare Blog: [Improving the Resiliency of Our Infrastructure DNS Zone](https://blog.cloudflare.com/improving-the-resiliency-of-our-infrastructure-dns-zone/)
If you know of any other resources, please do let us know!
## License ## License
OctoDNS is licensed under the [MIT license](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. 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.


+ 16
- 0
docs/records.md View File

@ -10,6 +10,7 @@ OctoDNS supports the following record types:
* `CAA` * `CAA`
* `CNAME` * `CNAME`
* `DNAME` * `DNAME`
* `LOC`
* `MX` * `MX`
* `NAPTR` * `NAPTR`
* `NS` * `NS`
@ -120,3 +121,18 @@ If you'd like to enable lenience for a whole zone you can do so with the followi
targets: targets:
- ns1 - 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
````

+ 11
- 5
octodns/manager.py View File

@ -314,7 +314,7 @@ class Manager(object):
self.log.error('Invalid alias zone {}, target {} does ' self.log.error('Invalid alias zone {}, target {} does '
'not exist'.format(zone_name, source_zone)) 'not exist'.format(zone_name, source_zone))
raise ManagerException('Invalid alias zone {}: ' raise ManagerException('Invalid alias zone {}: '
'source zone {} does not exist'
'source zone {} does not exist'
.format(zone_name, source_zone)) .format(zone_name, source_zone))
# Check that the source zone is not an alias zone itself. # Check that the source zone is not an alias zone itself.
@ -322,7 +322,7 @@ class Manager(object):
self.log.error('Invalid alias zone {}, target {} is an ' self.log.error('Invalid alias zone {}, target {} is an '
'alias zone'.format(zone_name, source_zone)) 'alias zone'.format(zone_name, source_zone))
raise ManagerException('Invalid alias zone {}: source ' raise ManagerException('Invalid alias zone {}: source '
'zone {} is an alias zone'
'zone {} is an alias zone'
.format(zone_name, source_zone)) .format(zone_name, source_zone))
aliased_zones[zone_name] = source_zone aliased_zones[zone_name] = source_zone
@ -413,13 +413,19 @@ class Manager(object):
futures = [] futures = []
for zone_name, zone_source in aliased_zones.items(): for zone_name, zone_source in aliased_zones.items():
source_config = self.config['zones'][zone_source] 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( futures.append(self._executor.submit(
self._populate_and_plan, self._populate_and_plan,
zone_name, zone_name,
processors, processors,
[], [],
[self.providers[t] for t in source_config['targets']], [self.providers[t] for t in source_config['targets']],
desired=desired[zone_source],
desired=desired_config,
lenient=lenient lenient=lenient
)) ))
@ -521,13 +527,13 @@ class Manager(object):
if source_zone not in self.config['zones']: if source_zone not in self.config['zones']:
self.log.exception('Invalid alias zone') self.log.exception('Invalid alias zone')
raise ManagerException('Invalid alias zone {}: ' raise ManagerException('Invalid alias zone {}: '
'source zone {} does not exist'
'source zone {} does not exist'
.format(zone_name, source_zone)) .format(zone_name, source_zone))
if 'alias' in self.config['zones'][source_zone]: if 'alias' in self.config['zones'][source_zone]:
self.log.exception('Invalid alias zone') self.log.exception('Invalid alias zone')
raise ManagerException('Invalid alias zone {}: ' raise ManagerException('Invalid alias zone {}: '
'source zone {} is an alias zone'
'source zone {} is an alias zone'
.format(zone_name, source_zone)) .format(zone_name, source_zone))
# this is just here to satisfy coverage, see # this is just here to satisfy coverage, see


+ 97
- 31
octodns/provider/azuredns.py View File

@ -7,7 +7,6 @@ from __future__ import absolute_import, division, print_function, \
from azure.common.credentials import ServicePrincipalCredentials from azure.common.credentials import ServicePrincipalCredentials
from azure.mgmt.dns import DnsManagementClient from azure.mgmt.dns import DnsManagementClient
from msrestazure.azure_exceptions import CloudError
from azure.mgmt.dns.models import ARecord, AaaaRecord, CaaRecord, \ from azure.mgmt.dns.models import ARecord, AaaaRecord, CaaRecord, \
CnameRecord, MxRecord, SrvRecord, NsRecord, PtrRecord, TxtRecord, Zone CnameRecord, MxRecord, SrvRecord, NsRecord, PtrRecord, TxtRecord, Zone
@ -28,6 +27,25 @@ def unescape_semicolon(s):
return s.replace('\\;', ';') return s.replace('\\;', ';')
def azure_chunked_value(val):
CHUNK_SIZE = 255
val_replace = val.replace('"', '\\"')
value = unescape_semicolon(val_replace)
if len(val) > CHUNK_SIZE:
vs = [value[i:i + CHUNK_SIZE]
for i in range(0, len(value), CHUNK_SIZE)]
else:
vs = value
return vs
def azure_chunked_values(s):
values = []
for v in s:
values.append(azure_chunked_value(v))
return values
class _AzureRecord(object): class _AzureRecord(object):
'''Wrapper for OctoDNS record for AzureProvider to make dns_client calls. '''Wrapper for OctoDNS record for AzureProvider to make dns_client calls.
@ -72,6 +90,8 @@ class _AzureRecord(object):
:type return: _AzureRecord :type return: _AzureRecord
''' '''
self.log = logging.getLogger('AzureRecord')
self.resource_group = resource_group self.resource_group = resource_group
self.zone_name = record.zone.name[:len(record.zone.name) - 1] self.zone_name = record.zone.name[:len(record.zone.name) - 1]
self.relative_record_set_name = record.name or '@' self.relative_record_set_name = record.name or '@'
@ -162,11 +182,19 @@ class _AzureRecord(object):
return {key_name: [azure_class(ptrdname=v) for v in values]} return {key_name: [azure_class(ptrdname=v) for v in values]}
def _params_for_TXT(self, data, key_name, azure_class): def _params_for_TXT(self, data, key_name, azure_class):
params = []
try: # API for TxtRecord has list of str, even for singleton try: # API for TxtRecord has list of str, even for singleton
values = [unescape_semicolon(v) for v in data['values']]
values = [v for v in azure_chunked_values(data['values'])]
except KeyError: except KeyError:
values = [unescape_semicolon(data['value'])]
return {key_name: [azure_class(value=[v]) for v in values]}
values = [azure_chunked_value(data['value'])]
for v in values:
if isinstance(v, list):
params.append(azure_class(value=v))
else:
params.append(azure_class(value=[v]))
return {key_name: params}
def _equals(self, b): def _equals(self, b):
'''Checks whether two records are equal by comparing all fields. '''Checks whether two records are equal by comparing all fields.
@ -234,6 +262,13 @@ def _parse_azure_type(string):
return string.split('/')[len(string.split('/')) - 1] return string.split('/')[len(string.split('/')) - 1]
def _check_for_alias(azrecord):
if (azrecord.target_resource.id and not azrecord.arecords and not
azrecord.cname_record):
return True
return False
class AzureProvider(BaseProvider): class AzureProvider(BaseProvider):
''' '''
Azure DNS Provider Azure DNS Provider
@ -294,18 +329,36 @@ class AzureProvider(BaseProvider):
'key=***, directory_id:%s', id, client_id, directory_id) 'key=***, directory_id:%s', id, client_id, directory_id)
super(AzureProvider, self).__init__(id, *args, **kwargs) super(AzureProvider, self).__init__(id, *args, **kwargs)
credentials = ServicePrincipalCredentials(
client_id, secret=key, tenant=directory_id
)
self._dns_client = DnsManagementClient(credentials, sub_id)
# Store necessary initialization params
self._dns_client_handle = None
self._dns_client_client_id = client_id
self._dns_client_key = key
self._dns_client_directory_id = directory_id
self._dns_client_subscription_id = sub_id
self.__dns_client = None
self._resource_group = resource_group self._resource_group = resource_group
self._azure_zones = set() self._azure_zones = set()
@property
def _dns_client(self):
if self.__dns_client is None:
credentials = ServicePrincipalCredentials(
self._dns_client_client_id,
secret=self._dns_client_key,
tenant=self._dns_client_directory_id
)
self.__dns_client = DnsManagementClient(
credentials,
self._dns_client_subscription_id
)
return self.__dns_client
def _populate_zones(self): def _populate_zones(self):
self.log.debug('azure_zones: loading') self.log.debug('azure_zones: loading')
list_zones = self._dns_client.zones.list_by_resource_group list_zones = self._dns_client.zones.list_by_resource_group
for zone in list_zones(self._resource_group): for zone in list_zones(self._resource_group):
self._azure_zones.add(zone.name)
self._azure_zones.add(zone.name.rstrip('.'))
def _check_zone(self, name, create=False): def _check_zone(self, name, create=False):
'''Checks whether a zone specified in a source exist in Azure server. '''Checks whether a zone specified in a source exist in Azure server.
@ -320,29 +373,20 @@ class AzureProvider(BaseProvider):
:type return: str or None :type return: str or None
''' '''
self.log.debug('_check_zone: name=%s', name)
try:
if name in self._azure_zones:
return name
self._dns_client.zones.get(self._resource_group, name)
self.log.debug('_check_zone: name=%s create=%s', name, create)
# Check if the zone already exists in our set
if name in self._azure_zones:
return name
# If not, and its time to create, lets do it.
if create:
self.log.debug('_check_zone:no matching zone; creating %s', name)
create_zone = self._dns_client.zones.create_or_update
create_zone(self._resource_group, name, Zone(location='global'))
self._azure_zones.add(name) self._azure_zones.add(name)
return name return name
except CloudError as err:
msg = 'The Resource \'Microsoft.Network/dnszones/{}\''.format(name)
msg += ' under resource group \'{}\''.format(self._resource_group)
msg += ' was not found.'
if msg == err.message:
# Then the only error is that the zone doesn't currently exist
if create:
self.log.debug('_check_zone:no matching zone; creating %s',
name)
create_zone = self._dns_client.zones.create_or_update
create_zone(self._resource_group, name,
Zone(location='global'))
return name
else:
return
raise
else:
# Else return nothing (aka false)
return
def populate(self, zone, target=False, lenient=False): def populate(self, zone, target=False, lenient=False):
'''Required function of manager.py to collect records from zone. '''Required function of manager.py to collect records from zone.
@ -387,11 +431,20 @@ class AzureProvider(BaseProvider):
for azrecord in _records: for azrecord in _records:
record_name = azrecord.name if azrecord.name != '@' else '' record_name = azrecord.name if azrecord.name != '@' else ''
typ = _parse_azure_type(azrecord.type) typ = _parse_azure_type(azrecord.type)
if typ in ['A', 'CNAME']:
if _check_for_alias(azrecord):
self.log.debug(
'Skipping - ALIAS. zone=%s record=%s, type=%s',
zone_name, record_name, typ) # pragma: no cover
continue # pragma: no cover
data = getattr(self, '_data_for_{}'.format(typ)) data = getattr(self, '_data_for_{}'.format(typ))
data = data(azrecord) data = data(azrecord)
data['type'] = typ data['type'] = typ
data['ttl'] = azrecord.ttl data['ttl'] = azrecord.ttl
record = Record.new(zone, record_name, data, source=self) record = Record.new(zone, record_name, data, source=self)
zone.add_record(record, lenient=lenient) zone.add_record(record, lenient=lenient)
self.log.info('populate: found %s records, exists=%s', self.log.info('populate: found %s records, exists=%s',
@ -488,6 +541,19 @@ class AzureProvider(BaseProvider):
azure_zone_name = desired.name[:len(desired.name) - 1] azure_zone_name = desired.name[:len(desired.name) - 1]
self._check_zone(azure_zone_name, create=True) self._check_zone(azure_zone_name, create=True)
'''
Force the operation order to be Delete() before all other operations.
Helps avoid problems in updating
- a CNAME record into an A record.
- an A record into a CNAME record.
'''
for change in changes:
class_name = change.__class__.__name__
if class_name == 'Delete':
self._apply_Delete(change)
for change in changes: for change in changes:
class_name = change.__class__.__name__ class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name))(change)
if class_name != 'Delete':
getattr(self, '_apply_{}'.format(class_name))(change)

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

@ -94,7 +94,10 @@ class BaseProvider(BaseSource):
self.log.info('apply: disabled') self.log.info('apply: disabled')
return 0 return 0
self.log.info('apply: making changes')
zone_name = plan.desired.name
num_changes = len(plan.changes)
self.log.info('apply: making %d changes to %s', num_changes,
zone_name)
self._apply(plan) self._apply(plan)
return len(plan.changes) return len(plan.changes)


+ 79
- 5
octodns/provider/cloudflare.py View File

@ -75,8 +75,8 @@ class CloudflareProvider(BaseProvider):
''' '''
SUPPORTS_GEO = False SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False SUPPORTS_DYNAMIC = False
SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'PTR',
'SRV', 'SPF', 'TXT'))
SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'LOC', 'MX', 'NS',
'PTR', 'SRV', 'SPF', 'TXT'))
MIN_TTL = 120 MIN_TTL = 120
TIMEOUT = 15 TIMEOUT = 15
@ -133,6 +133,7 @@ class CloudflareProvider(BaseProvider):
timeout=self.TIMEOUT) timeout=self.TIMEOUT)
self.log.debug('_request: status=%d', resp.status_code) self.log.debug('_request: status=%d', resp.status_code)
if resp.status_code == 400: if resp.status_code == 400:
self.log.debug('_request: data=%s', data)
raise CloudflareError(resp.json()) raise CloudflareError(resp.json())
if resp.status_code == 403: if resp.status_code == 403:
raise CloudflareAuthenticationError(resp.json()) raise CloudflareAuthenticationError(resp.json())
@ -142,6 +143,11 @@ class CloudflareProvider(BaseProvider):
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
def _change_keyer(self, change):
key = change.__class__.__name__
order = {'Delete': 0, 'Create': 1, 'Update': 2}
return order[key]
@property @property
def zones(self): def zones(self):
if self._zones is None: if self._zones is None:
@ -216,6 +222,30 @@ class CloudflareProvider(BaseProvider):
_data_for_ALIAS = _data_for_CNAME _data_for_ALIAS = _data_for_CNAME
_data_for_PTR = _data_for_CNAME _data_for_PTR = _data_for_CNAME
def _data_for_LOC(self, _type, records):
values = []
for record in records:
r = record['data']
values.append({
'lat_degrees': int(r['lat_degrees']),
'lat_minutes': int(r['lat_minutes']),
'lat_seconds': float(r['lat_seconds']),
'lat_direction': r['lat_direction'],
'long_degrees': int(r['long_degrees']),
'long_minutes': int(r['long_minutes']),
'long_seconds': float(r['long_seconds']),
'long_direction': r['long_direction'],
'altitude': float(r['altitude']),
'size': float(r['size']),
'precision_horz': float(r['precision_horz']),
'precision_vert': float(r['precision_vert']),
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_MX(self, _type, records): def _data_for_MX(self, _type, records):
values = [] values = []
for r in records: for r in records:
@ -239,11 +269,13 @@ class CloudflareProvider(BaseProvider):
def _data_for_SRV(self, _type, records): def _data_for_SRV(self, _type, records):
values = [] values = []
for r in records: for r in records:
target = ('{}.'.format(r['data']['target'])
if r['data']['target'] != "." else ".")
values.append({ values.append({
'priority': r['data']['priority'], 'priority': r['data']['priority'],
'weight': r['data']['weight'], 'weight': r['data']['weight'],
'port': r['data']['port'], 'port': r['data']['port'],
'target': '{}.'.format(r['data']['target']),
'target': target,
}) })
return { return {
'type': _type, 'type': _type,
@ -384,6 +416,25 @@ class CloudflareProvider(BaseProvider):
_contents_for_PTR = _contents_for_CNAME _contents_for_PTR = _contents_for_CNAME
def _contents_for_LOC(self, record):
for value in record.values:
yield {
'data': {
'lat_degrees': value.lat_degrees,
'lat_minutes': value.lat_minutes,
'lat_seconds': value.lat_seconds,
'lat_direction': value.lat_direction,
'long_degrees': value.long_degrees,
'long_minutes': value.long_minutes,
'long_seconds': value.long_seconds,
'long_direction': value.long_direction,
'altitude': value.altitude,
'size': value.size,
'precision_horz': value.precision_horz,
'precision_vert': value.precision_vert,
}
}
def _contents_for_MX(self, record): def _contents_for_MX(self, record):
for value in record.values: for value in record.values:
yield { yield {
@ -405,6 +456,8 @@ class CloudflareProvider(BaseProvider):
name = subdomain name = subdomain
for value in record.values: for value in record.values:
target = value.target[:-1] if value.target != "." else "."
yield { yield {
'data': { 'data': {
'service': service, 'service': service,
@ -413,7 +466,7 @@ class CloudflareProvider(BaseProvider):
'priority': value.priority, 'priority': value.priority,
'weight': value.weight, 'weight': value.weight,
'port': value.port, 'port': value.port,
'target': value.target[:-1],
'target': target,
} }
} }
@ -456,7 +509,7 @@ class CloudflareProvider(BaseProvider):
# new records cleanly. In general when there are multiple records for a # new records cleanly. In general when there are multiple records for a
# name & type each will have a distinct/consistent `content` that can # name & type each will have a distinct/consistent `content` that can
# serve as a unique identifier. # serve as a unique identifier.
# BUT... there are exceptions. MX, CAA, and SRV don't have a simple
# BUT... there are exceptions. MX, CAA, LOC and SRV don't have a simple
# content as things are currently implemented so we need to handle # content as things are currently implemented so we need to handle
# those explicitly and create unique/hashable strings for them. # those explicitly and create unique/hashable strings for them.
_type = data['type'] _type = data['type']
@ -468,6 +521,22 @@ class CloudflareProvider(BaseProvider):
elif _type == 'SRV': elif _type == 'SRV':
data = data['data'] data = data['data']
return '{port} {priority} {target} {weight}'.format(**data) return '{port} {priority} {target} {weight}'.format(**data)
elif _type == 'LOC':
data = data['data']
loc = (
'{lat_degrees}',
'{lat_minutes}',
'{lat_seconds}',
'{lat_direction}',
'{long_degrees}',
'{long_minutes}',
'{long_seconds}',
'{long_direction}',
'{altitude}',
'{size}',
'{precision_horz}',
'{precision_vert}')
return ' '.join(loc).format(**data)
return data['content'] return data['content']
def _apply_Create(self, change): def _apply_Create(self, change):
@ -616,6 +685,11 @@ class CloudflareProvider(BaseProvider):
self.zones[name] = zone_id self.zones[name] = zone_id
self._zone_records[name] = {} self._zone_records[name] = {}
# Force the operation order to be Delete() -> Create() -> Update()
# This will help avoid problems in updating a CNAME record into an
# A record and vice-versa
changes.sort(key=self._change_keyer)
for change in changes: for change in changes:
class_name = change.__class__.__name__ class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name))(change) getattr(self, '_apply_{}'.format(class_name))(change)


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

@ -186,10 +186,14 @@ class DigitalOceanProvider(BaseProvider):
def _data_for_SRV(self, _type, records): def _data_for_SRV(self, _type, records):
values = [] values = []
for record in records: for record in records:
target = (
'{}.'.format(record['data'])
if record['data'] != "." else "."
)
values.append({ values.append({
'port': record['port'], 'port': record['port'],
'priority': record['priority'], 'priority': record['priority'],
'target': '{}.'.format(record['data']),
'target': target,
'weight': record['weight'] 'weight': record['weight']
}) })
return { return {


+ 38
- 2
octodns/provider/dnsimple.py View File

@ -218,12 +218,23 @@ class DnsimpleProvider(BaseProvider):
try: try:
weight, port, target = record['content'].split(' ', 2) weight, port, target = record['content'].split(' ', 2)
except ValueError: except ValueError:
# see _data_for_NAPTR's continue
# their api/website will let you create invalid records, this
# essentially handles that by ignoring them for values
# purposes. That will cause updates to happen to delete them if
# they shouldn't exist or update them if they're wrong
self.log.warning(
'_data_for_SRV: unsupported %s record (%s)',
_type,
record['content']
)
continue continue
target = '{}.'.format(target) if target != "." else "."
values.append({ values.append({
'port': port, 'port': port,
'priority': record['priority'], 'priority': record['priority'],
'target': '{}.'.format(target),
'target': target,
'weight': weight 'weight': weight
}) })
return { return {
@ -270,6 +281,10 @@ class DnsimpleProvider(BaseProvider):
for record in self.zone_records(zone): for record in self.zone_records(zone):
_type = record['type'] _type = record['type']
if _type not in self.SUPPORTS: if _type not in self.SUPPORTS:
self.log.warning(
'populate: skipping unsupported %s record',
_type
)
continue continue
elif _type == 'TXT' and record['content'].startswith('ALIAS for'): elif _type == 'TXT' and record['content'].startswith('ALIAS for'):
# ALIAS has a "ride along" TXT record with 'ALIAS for XXXX', # ALIAS has a "ride along" TXT record with 'ALIAS for XXXX',
@ -290,6 +305,27 @@ class DnsimpleProvider(BaseProvider):
len(zone.records) - before, exists) len(zone.records) - before, exists)
return exists return exists
def supports(self, record):
# DNSimple does not support empty/NULL SRV records
#
# Fails silently and leaves a corrupt record
#
# Skip the record and continue
if record._type == "SRV":
if 'value' in record.data:
targets = (record.data['value']['target'],)
else:
targets = [value['target'] for value in record.data['values']]
if "." in targets:
self.log.warning(
'supports: unsupported %s record with target (%s)',
record._type, targets
)
return False
return super(DnsimpleProvider, self).supports(record)
def _params_for_multiple(self, record): def _params_for_multiple(self, record):
for value in record.values: for value in record.values:
yield { yield {


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

@ -284,6 +284,30 @@ class DnsMadeEasyProvider(BaseProvider):
len(zone.records) - before, exists) len(zone.records) - before, exists)
return exists return exists
def supports(self, record):
# DNS Made Easy does not support empty/NULL SRV records
#
# Attempting to sync such a record would generate the following error
#
# octodns.provider.dnsmadeeasy.DnsMadeEasyClientBadRequest:
# - Record value may not be a standalone dot.
#
# Skip the record and continue
if record._type == "SRV":
if 'value' in record.data:
targets = (record.data['value']['target'],)
else:
targets = [value['target'] for value in record.data['values']]
if "." in targets:
self.log.warning(
'supports: unsupported %s record with target (%s)',
record._type, targets
)
return False
return super(DnsMadeEasyProvider, self).supports(record)
def _params_for_multiple(self, record): def _params_for_multiple(self, record):
for value in record.values: for value in record.values:
yield { yield {


+ 1
- 1
octodns/provider/gandi.py View File

@ -357,7 +357,7 @@ class GandiProvider(BaseProvider):
# We suppress existing exception before raising # We suppress existing exception before raising
# GandiClientUnknownDomainName. # GandiClientUnknownDomainName.
e = GandiClientUnknownDomainName('This domain is not ' e = GandiClientUnknownDomainName('This domain is not '
'registred at Gandi. '
'registered at Gandi. '
'Please register or ' 'Please register or '
'transfer it here ' 'transfer it here '
'to be able to manage its ' 'to be able to manage its '


+ 57
- 2
octodns/provider/powerdns.py View File

@ -6,6 +6,7 @@ from __future__ import absolute_import, division, print_function, \
unicode_literals unicode_literals
from requests import HTTPError, Session from requests import HTTPError, Session
from operator import itemgetter
import logging import logging
from ..record import Create, Record from ..record import Create, Record
@ -15,8 +16,8 @@ from .base import BaseProvider
class PowerDnsBaseProvider(BaseProvider): class PowerDnsBaseProvider(BaseProvider):
SUPPORTS_GEO = False SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS',
'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT'))
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'LOC', 'MX', 'NAPTR',
'NS', 'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT'))
TIMEOUT = 5 TIMEOUT = 5
def __init__(self, id, host, api_key, port=8081, def __init__(self, id, host, api_key, port=8081,
@ -102,6 +103,33 @@ class PowerDnsBaseProvider(BaseProvider):
_data_for_SPF = _data_for_quoted _data_for_SPF = _data_for_quoted
_data_for_TXT = _data_for_quoted _data_for_TXT = _data_for_quoted
def _data_for_LOC(self, rrset):
values = []
for record in rrset['records']:
lat_degrees, lat_minutes, lat_seconds, lat_direction, \
long_degrees, long_minutes, long_seconds, long_direction, \
altitude, size, precision_horz, precision_vert = \
record['content'].replace('m', '').split(' ', 11)
values.append({
'lat_degrees': int(lat_degrees),
'lat_minutes': int(lat_minutes),
'lat_seconds': float(lat_seconds),
'lat_direction': lat_direction,
'long_degrees': int(long_degrees),
'long_minutes': int(long_minutes),
'long_seconds': float(long_seconds),
'long_direction': long_direction,
'altitude': float(altitude),
'size': float(size),
'precision_horz': float(precision_horz),
'precision_vert': float(precision_vert),
})
return {
'ttl': rrset['ttl'],
'type': rrset['type'],
'values': values
}
def _data_for_MX(self, rrset): def _data_for_MX(self, rrset):
values = [] values = []
for record in rrset['records']: for record in rrset['records']:
@ -285,6 +313,27 @@ class PowerDnsBaseProvider(BaseProvider):
_records_for_SPF = _records_for_quoted _records_for_SPF = _records_for_quoted
_records_for_TXT = _records_for_quoted _records_for_TXT = _records_for_quoted
def _records_for_LOC(self, record):
return [{
'content':
'%d %d %0.3f %s %d %d %.3f %s %0.2fm %0.2fm %0.2fm %0.2fm' %
(
int(v.lat_degrees),
int(v.lat_minutes),
float(v.lat_seconds),
v.lat_direction,
int(v.long_degrees),
int(v.long_minutes),
float(v.long_seconds),
v.long_direction,
float(v.altitude),
float(v.size),
float(v.precision_horz),
float(v.precision_vert)
),
'disabled': False
} for v in record.values]
def _records_for_MX(self, record): def _records_for_MX(self, record):
return [{ return [{
'content': '{} {}'.format(v.preference, v.exchange), 'content': '{} {}'.format(v.preference, v.exchange),
@ -381,6 +430,12 @@ class PowerDnsBaseProvider(BaseProvider):
for change in changes: for change in changes:
class_name = change.__class__.__name__ class_name = change.__class__.__name__
mods.append(getattr(self, '_mod_{}'.format(class_name))(change)) mods.append(getattr(self, '_mod_{}'.format(class_name))(change))
# Ensure that any DELETE modifications always occur before any REPLACE
# modifications. This ensures that an A record can be replaced by a
# CNAME record and vice-versa.
mods.sort(key=itemgetter('changetype'))
self.log.debug('_apply: sending change request') self.log.debug('_apply: sending change request')
try: try:


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

@ -287,7 +287,13 @@ class UltraProvider(BaseProvider):
name = zone.hostname_from_fqdn(record['ownerName']) name = zone.hostname_from_fqdn(record['ownerName'])
if record['rrtype'] == 'SOA (6)': if record['rrtype'] == 'SOA (6)':
continue 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 values[name][_type] = record
for name, types in values.items(): for name, types in values.items():


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

@ -104,7 +104,7 @@ class YamlProvider(BaseProvider):
''' '''
SUPPORTS_GEO = True SUPPORTS_GEO = True
SUPPORTS_DYNAMIC = True SUPPORTS_DYNAMIC = True
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', 'MX',
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', 'LOC', 'MX',
'NAPTR', 'NS', 'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT')) 'NAPTR', 'NS', 'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT'))
def __init__(self, id, directory, default_ttl=3600, enforce_order=True, def __init__(self, id, directory, default_ttl=3600, enforce_order=True,
@ -239,11 +239,13 @@ class SplitYamlProvider(YamlProvider):
# instead of a file matching the record name. # instead of a file matching the record name.
CATCHALL_RECORD_NAMES = ('*', '') CATCHALL_RECORD_NAMES = ('*', '')
def __init__(self, id, directory, *args, **kwargs):
def __init__(self, id, directory, extension='.', *args, **kwargs):
super(SplitYamlProvider, self).__init__(id, directory, *args, **kwargs) super(SplitYamlProvider, self).__init__(id, directory, *args, **kwargs)
self.extension = extension
def _zone_directory(self, zone): def _zone_directory(self, zone):
return join(self.directory, zone.name)
filename = '{}{}'.format(zone.name[:-1], self.extension)
return join(self.directory, filename)
def populate(self, zone, target=False, lenient=False): def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,


+ 193
- 1
octodns/record/__init__.py View File

@ -97,6 +97,7 @@ class Record(EqualityTupleMixin):
'CAA': CaaRecord, 'CAA': CaaRecord,
'CNAME': CnameRecord, 'CNAME': CnameRecord,
'DNAME': DnameRecord, 'DNAME': DnameRecord,
'LOC': LocRecord,
'MX': MxRecord, 'MX': MxRecord,
'NAPTR': NaptrRecord, 'NAPTR': NaptrRecord,
'NS': NsRecord, 'NS': NsRecord,
@ -758,7 +759,9 @@ class _TargetValue(object):
reasons.append('empty value') reasons.append('empty value')
elif not data: elif not data:
reasons.append('missing value') reasons.append('missing value')
elif not FQDN(data, allow_underscores=True).is_valid:
# NOTE: FQDN complains if the data it receives isn't a str, it doesn't
# allow unicode... This is likely specific to 2.7
elif not FQDN(str(data), allow_underscores=True).is_valid:
reasons.append('{} value "{}" is not a valid FQDN' reasons.append('{} value "{}" is not a valid FQDN'
.format(_type, data)) .format(_type, data))
elif not data.endswith('.'): elif not data.endswith('.'):
@ -877,6 +880,195 @@ class DnameRecord(_DynamicMixin, _ValueMixin, Record):
_value_type = DnameValue _value_type = DnameValue
class LocValue(EqualityTupleMixin):
# TODO: work out how to do defaults per RFC
@classmethod
def validate(cls, data, _type):
int_keys = [
'lat_degrees',
'lat_minutes',
'long_degrees',
'long_minutes',
]
float_keys = [
'lat_seconds',
'long_seconds',
'altitude',
'size',
'precision_horz',
'precision_vert',
]
direction_keys = [
'lat_direction',
'long_direction',
]
if not isinstance(data, (list, tuple)):
data = (data,)
reasons = []
for value in data:
for key in int_keys:
try:
int(value[key])
if (
(
key == 'lat_degrees' and
not 0 <= int(value[key]) <= 90
) or (
key == 'long_degrees' and
not 0 <= int(value[key]) <= 180
) or (
key in ['lat_minutes', 'long_minutes'] and
not 0 <= int(value[key]) <= 59
)
):
reasons.append('invalid value for {} "{}"'
.format(key, value[key]))
except KeyError:
reasons.append('missing {}'.format(key))
except ValueError:
reasons.append('invalid {} "{}"'
.format(key, value[key]))
for key in float_keys:
try:
float(value[key])
if (
(
key in ['lat_seconds', 'long_seconds'] and
not 0 <= float(value[key]) <= 59.999
) or (
key == 'altitude' and
not -100000.00 <= float(value[key]) <= 42849672.95
) or (
key in ['size',
'precision_horz',
'precision_vert'] and
not 0 <= float(value[key]) <= 90000000.00
)
):
reasons.append('invalid value for {} "{}"'
.format(key, value[key]))
except KeyError:
reasons.append('missing {}'.format(key))
except ValueError:
reasons.append('invalid {} "{}"'
.format(key, value[key]))
for key in direction_keys:
try:
str(value[key])
if (
key == 'lat_direction' and
value[key] not in ['N', 'S']
):
reasons.append('invalid direction for {} "{}"'
.format(key, value[key]))
if (
key == 'long_direction' and
value[key] not in ['E', 'W']
):
reasons.append('invalid direction for {} "{}"'
.format(key, value[key]))
except KeyError:
reasons.append('missing {}'.format(key))
return reasons
@classmethod
def process(cls, values):
return [LocValue(v) for v in values]
def __init__(self, value):
self.lat_degrees = int(value['lat_degrees'])
self.lat_minutes = int(value['lat_minutes'])
self.lat_seconds = float(value['lat_seconds'])
self.lat_direction = value['lat_direction'].upper()
self.long_degrees = int(value['long_degrees'])
self.long_minutes = int(value['long_minutes'])
self.long_seconds = float(value['long_seconds'])
self.long_direction = value['long_direction'].upper()
self.altitude = float(value['altitude'])
self.size = float(value['size'])
self.precision_horz = float(value['precision_horz'])
self.precision_vert = float(value['precision_vert'])
@property
def data(self):
return {
'lat_degrees': self.lat_degrees,
'lat_minutes': self.lat_minutes,
'lat_seconds': self.lat_seconds,
'lat_direction': self.lat_direction,
'long_degrees': self.long_degrees,
'long_minutes': self.long_minutes,
'long_seconds': self.long_seconds,
'long_direction': self.long_direction,
'altitude': self.altitude,
'size': self.size,
'precision_horz': self.precision_horz,
'precision_vert': self.precision_vert,
}
def __hash__(self):
return hash((
self.lat_degrees,
self.lat_minutes,
self.lat_seconds,
self.lat_direction,
self.long_degrees,
self.long_minutes,
self.long_seconds,
self.long_direction,
self.altitude,
self.size,
self.precision_horz,
self.precision_vert,
))
def _equality_tuple(self):
return (
self.lat_degrees,
self.lat_minutes,
self.lat_seconds,
self.lat_direction,
self.long_degrees,
self.long_minutes,
self.long_seconds,
self.long_direction,
self.altitude,
self.size,
self.precision_horz,
self.precision_vert,
)
def __repr__(self):
loc_format = "'{0} {1} {2:.3f} {3} " + \
"{4} {5} {6:.3f} {7} " + \
"{8:.2f}m {9:.2f}m {10:.2f}m {11:.2f}m'"
return loc_format.format(
self.lat_degrees,
self.lat_minutes,
self.lat_seconds,
self.lat_direction,
self.long_degrees,
self.long_minutes,
self.long_seconds,
self.long_direction,
self.altitude,
self.size,
self.precision_horz,
self.precision_vert,
)
class LocRecord(_ValuesMixin, Record):
_type = 'LOC'
_value_type = LocValue
class MxValue(EqualityTupleMixin): class MxValue(EqualityTupleMixin):
@classmethod @classmethod


+ 42
- 7
octodns/source/axfr.py View File

@ -26,8 +26,8 @@ class AxfrBaseSource(BaseSource):
SUPPORTS_GEO = False SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'PTR', 'SPF',
'SRV', 'TXT'))
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'LOC', 'MX', 'NS', 'PTR',
'SPF', 'SRV', 'TXT'))
def __init__(self, id): def __init__(self, id):
super(AxfrBaseSource, self).__init__(id) super(AxfrBaseSource, self).__init__(id)
@ -58,6 +58,33 @@ class AxfrBaseSource(BaseSource):
'values': values 'values': values
} }
def _data_for_LOC(self, _type, records):
values = []
for record in records:
lat_degrees, lat_minutes, lat_seconds, lat_direction, \
long_degrees, long_minutes, long_seconds, long_direction, \
altitude, size, precision_horz, precision_vert = \
record['value'].replace('m', '').split(' ', 11)
values.append({
'lat_degrees': lat_degrees,
'lat_minutes': lat_minutes,
'lat_seconds': lat_seconds,
'lat_direction': lat_direction,
'long_degrees': long_degrees,
'long_minutes': long_minutes,
'long_seconds': long_seconds,
'long_direction': long_direction,
'altitude': altitude,
'size': size,
'precision_horz': precision_horz,
'precision_vert': precision_vert,
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_MX(self, _type, records): def _data_for_MX(self, _type, records):
values = [] values = []
for record in records: for record in records:
@ -206,26 +233,34 @@ class ZoneFileSource(AxfrBaseSource):
class: octodns.source.axfr.ZoneFileSource class: octodns.source.axfr.ZoneFileSource
# The directory holding the zone files # The directory holding the zone files
# Filenames should match zone name (eg. example.com.) # Filenames should match zone name (eg. example.com.)
# with optional extension specified with file_extension
directory: ./zonefiles directory: ./zonefiles
# File extension on zone files
# Appended to zone name to locate file
# (optional, default None)
file_extension: zone
# Should sanity checks of the origin node be done # Should sanity checks of the origin node be done
# (optional, default true) # (optional, default true)
check_origin: false check_origin: false
''' '''
def __init__(self, id, directory, check_origin=True):
def __init__(self, id, directory, file_extension='.', check_origin=True):
self.log = logging.getLogger('ZoneFileSource[{}]'.format(id)) self.log = logging.getLogger('ZoneFileSource[{}]'.format(id))
self.log.debug('__init__: id=%s, directory=%s, check_origin=%s', id,
directory, check_origin)
self.log.debug('__init__: id=%s, directory=%s, file_extension=%s, '
'check_origin=%s', id,
directory, file_extension, check_origin)
super(ZoneFileSource, self).__init__(id) super(ZoneFileSource, self).__init__(id)
self.directory = directory self.directory = directory
self.file_extension = file_extension
self.check_origin = check_origin self.check_origin = check_origin
self._zone_records = {} self._zone_records = {}
def _load_zone_file(self, zone_name): def _load_zone_file(self, zone_name):
zone_filename = '{}{}'.format(zone_name[:-1], self.file_extension)
zonefiles = listdir(self.directory) zonefiles = listdir(self.directory)
if zone_name in zonefiles:
if zone_filename in zonefiles:
try: try:
z = dns.zone.from_file(join(self.directory, zone_name),
z = dns.zone.from_file(join(self.directory, zone_filename),
zone_name, relativize=False, zone_name, relativize=False,
check_origin=self.check_origin) check_origin=self.check_origin)
except DNSException as error: except DNSException as error:


+ 1
- 1
requirements-dev.txt View File

@ -5,4 +5,4 @@ pycodestyle==2.6.0
pyflakes==2.2.0 pyflakes==2.2.0
readme_renderer[md]==26.0 readme_renderer[md]==26.0
requests_mock requests_mock
twine==1.15.0
twine==3.2.0; python_version >= '3.2'

+ 2
- 1
setup.py View File

@ -69,6 +69,7 @@ setup(
'PyYaml>=4.2b1', 'PyYaml>=4.2b1',
'dnspython>=1.15.0', 'dnspython>=1.15.0',
'futures>=3.2.0; python_version<"3.2"', 'futures>=3.2.0; python_version<"3.2"',
'fqdn>=1.5.0',
'ipaddress>=1.0.22; python_version<"3.3"', 'ipaddress>=1.0.22; python_version<"3.3"',
'natsort>=5.5.0', 'natsort>=5.5.0',
'pycountry>=19.8.18', 'pycountry>=19.8.18',
@ -81,6 +82,6 @@ setup(
long_description_content_type='text/markdown', long_description_content_type='text/markdown',
name='octodns', name='octodns',
packages=find_packages(), packages=find_packages(),
url='https://github.com/github/octodns',
url='https://github.com/octodns/octodns',
version=octodns.__VERSION__, version=octodns.__VERSION__,
) )

+ 3
- 0
tests/config/simple-split.yaml View File

@ -4,14 +4,17 @@ providers:
in: in:
class: octodns.provider.yaml.SplitYamlProvider class: octodns.provider.yaml.SplitYamlProvider
directory: tests/config/split directory: tests/config/split
extension: .tst
dump: dump:
class: octodns.provider.yaml.SplitYamlProvider class: octodns.provider.yaml.SplitYamlProvider
directory: env/YAML_TMP_DIR directory: env/YAML_TMP_DIR
extension: .tst
# This is sort of ugly, but it shouldn't hurt anything. It'll just write out # This is sort of ugly, but it shouldn't hurt anything. It'll just write out
# the target file twice where it and dump are both used # the target file twice where it and dump are both used
dump2: dump2:
class: octodns.provider.yaml.SplitYamlProvider class: octodns.provider.yaml.SplitYamlProvider
directory: env/YAML_TMP_DIR directory: env/YAML_TMP_DIR
extension: .tst
simple: simple:
class: helpers.SimpleProvider class: helpers.SimpleProvider
geo: geo:


tests/config/split/dynamic.tests./a.yaml → tests/config/split/dynamic.tests.tst/a.yaml View File


tests/config/split/dynamic.tests./aaaa.yaml → tests/config/split/dynamic.tests.tst/aaaa.yaml View File


tests/config/split/dynamic.tests./cname.yaml → tests/config/split/dynamic.tests.tst/cname.yaml View File


tests/config/split/dynamic.tests./real-ish-a.yaml → tests/config/split/dynamic.tests.tst/real-ish-a.yaml View File


tests/config/split/dynamic.tests./simple-weighted.yaml → tests/config/split/dynamic.tests.tst/simple-weighted.yaml View File


tests/config/split/empty./.gitkeep → tests/config/split/empty.tst/.gitkeep View File


tests/config/split/subzone.unit.tests./12.yaml → tests/config/split/subzone.unit.tests.tst/12.yaml View File


tests/config/split/subzone.unit.tests./2.yaml → tests/config/split/subzone.unit.tests.tst/2.yaml View File


tests/config/split/subzone.unit.tests./test.yaml → tests/config/split/subzone.unit.tests.tst/test.yaml View File


tests/config/split/unit.tests./$unit.tests.yaml → tests/config/split/unit.tests.tst/$unit.tests.yaml View File


tests/config/split/unit.tests./_srv._tcp.yaml → tests/config/split/unit.tests.tst/_srv._tcp.yaml View File


tests/config/split/unit.tests./aaaa.yaml → tests/config/split/unit.tests.tst/aaaa.yaml View File


tests/config/split/unit.tests./cname.yaml → tests/config/split/unit.tests.tst/cname.yaml View File


tests/config/split/unit.tests./dname.yaml → tests/config/split/unit.tests.tst/dname.yaml View File


tests/config/split/unit.tests./excluded.yaml → tests/config/split/unit.tests.tst/excluded.yaml View File


tests/config/split/unit.tests./ignored.yaml → tests/config/split/unit.tests.tst/ignored.yaml View File


tests/config/split/unit.tests./included.yaml → tests/config/split/unit.tests.tst/included.yaml View File


tests/config/split/unit.tests./mx.yaml → tests/config/split/unit.tests.tst/mx.yaml View File


tests/config/split/unit.tests./naptr.yaml → tests/config/split/unit.tests.tst/naptr.yaml View File


tests/config/split/unit.tests./ptr.yaml → tests/config/split/unit.tests.tst/ptr.yaml View File


tests/config/split/unit.tests./spf.yaml → tests/config/split/unit.tests.tst/spf.yaml View File


tests/config/split/unit.tests./sub.yaml → tests/config/split/unit.tests.tst/sub.yaml View File


tests/config/split/unit.tests./txt.yaml → tests/config/split/unit.tests.tst/txt.yaml View File


tests/config/split/unit.tests./www.sub.yaml → tests/config/split/unit.tests.tst/www.sub.yaml View File


tests/config/split/unit.tests./www.yaml → tests/config/split/unit.tests.tst/www.yaml View File


tests/config/split/unordered./abc.yaml → tests/config/split/unordered.tst/abc.yaml View File


tests/config/split/unordered./xyz.yaml → tests/config/split/unordered.tst/xyz.yaml View File


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

@ -36,6 +36,22 @@
- flags: 0 - flags: 0
tag: issue tag: issue
value: ca.unit.tests value: ca.unit.tests
_imap._tcp:
ttl: 600
type: SRV
values:
- port: 0
priority: 0
target: .
weight: 0
_pop3._tcp:
ttl: 600
type: SRV
values:
- port: 0
priority: 0
target: .
weight: 0
_srv._tcp: _srv._tcp:
ttl: 600 ttl: 600
type: SRV type: SRV
@ -77,6 +93,34 @@ included:
- test - test
type: CNAME type: CNAME
value: unit.tests. value: unit.tests.
loc:
ttl: 300
type: LOC
values:
- altitude: 20
lat_degrees: 31
lat_direction: S
lat_minutes: 58
lat_seconds: 52.1
long_degrees: 115
long_direction: E
long_minutes: 49
long_seconds: 11.7
precision_horz: 10
precision_vert: 2
size: 10
- altitude: 20
lat_degrees: 53
lat_direction: N
lat_minutes: 13
lat_seconds: 10
long_degrees: 2
long_direction: W
long_minutes: 18
long_seconds: 26
precision_horz: 1000
precision_vert: 2
size: 10
mx: mx:
ttl: 300 ttl: 300
type: MX type: MX


+ 16
- 16
tests/fixtures/cloudflare-dns_records-page-2.json View File

@ -177,15 +177,15 @@
{ {
"id": "fc12ab34cd5611334422ab3322997656", "id": "fc12ab34cd5611334422ab3322997656",
"type": "SRV", "type": "SRV",
"name": "_srv._tcp.unit.tests",
"name": "_imap._tcp.unit.tests",
"data": { "data": {
"service": "_srv",
"service": "_imap",
"proto": "_tcp", "proto": "_tcp",
"name": "unit.tests", "name": "unit.tests",
"priority": 12,
"weight": 20,
"port": 30,
"target": "foo-2.unit.tests"
"priority": 0,
"weight": 0,
"port": 0,
"target": "."
}, },
"proxiable": true, "proxiable": true,
"proxied": false, "proxied": false,
@ -202,15 +202,15 @@
{ {
"id": "fc12ab34cd5611334422ab3322997656", "id": "fc12ab34cd5611334422ab3322997656",
"type": "SRV", "type": "SRV",
"name": "_srv._tcp.unit.tests",
"name": "_pop3._tcp.unit.tests",
"data": { "data": {
"service": "_srv",
"proto": "_tcp",
"service": "_imap",
"proto": "_pop3",
"name": "unit.tests", "name": "unit.tests",
"priority": 10,
"weight": 20,
"port": 30,
"target": "foo-1.unit.tests"
"priority": 0,
"weight": 0,
"port": 0,
"target": "."
}, },
"proxiable": true, "proxiable": true,
"proxied": false, "proxied": false,
@ -227,10 +227,10 @@
], ],
"result_info": { "result_info": {
"page": 2, "page": 2,
"per_page": 11,
"total_pages": 2,
"per_page": 10,
"total_pages": 3,
"count": 10, "count": 10,
"total_count": 20
"total_count": 24
}, },
"success": true, "success": true,
"errors": [], "errors": [],


+ 128
- 0
tests/fixtures/cloudflare-dns_records-page-3.json View File

@ -0,0 +1,128 @@
{
"result": [
{
"id": "fc12ab34cd5611334422ab3322997656",
"type": "SRV",
"name": "_srv._tcp.unit.tests",
"data": {
"service": "_srv",
"proto": "_tcp",
"name": "unit.tests",
"priority": 12,
"weight": 20,
"port": 30,
"target": "foo-2.unit.tests"
},
"proxiable": true,
"proxied": false,
"ttl": 600,
"locked": false,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.940682Z",
"created_on": "2017-03-11T18:01:43.940682Z",
"meta": {
"auto_added": false
}
},
{
"id": "fc12ab34cd5611334422ab3322997656",
"type": "SRV",
"name": "_srv._tcp.unit.tests",
"data": {
"service": "_srv",
"proto": "_tcp",
"name": "unit.tests",
"priority": 10,
"weight": 20,
"port": 30,
"target": "foo-1.unit.tests"
},
"proxiable": true,
"proxied": false,
"ttl": 600,
"locked": false,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.940682Z",
"created_on": "2017-03-11T18:01:43.940682Z",
"meta": {
"auto_added": false
}
},
{
"id": "372e67954025e0ba6aaa6d586b9e0b59",
"type": "LOC",
"name": "loc.unit.tests",
"content": "IN LOC 31 58 52.1 S 115 49 11.7 E 20m 10m 10m 2m",
"proxiable": true,
"proxied": false,
"ttl": 300,
"locked": false,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"created_on": "2020-01-28T05:20:00.12345Z",
"modified_on": "2020-01-28T05:20:00.12345Z",
"data": {
"lat_degrees": 31,
"lat_minutes": 58,
"lat_seconds": 52.1,
"lat_direction": "S",
"long_degrees": 115,
"long_minutes": 49,
"long_seconds": 11.7,
"long_direction": "E",
"altitude": 20,
"size": 10,
"precision_horz": 10,
"precision_vert": 2
},
"meta": {
"auto_added": true,
"source": "primary"
}
},
{
"id": "372e67954025e0ba6aaa6d586b9e0b59",
"type": "LOC",
"name": "loc.unit.tests",
"content": "IN LOC 53 14 10 N 2 18 26 W 20m 10m 1000m 2m",
"proxiable": true,
"proxied": false,
"ttl": 300,
"locked": false,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"created_on": "2020-01-28T05:20:00.12345Z",
"modified_on": "2020-01-28T05:20:00.12345Z",
"data": {
"lat_degrees": 53,
"lat_minutes": 13,
"lat_seconds": 10,
"lat_direction": "N",
"long_degrees": 2,
"long_minutes": 18,
"long_seconds": 26,
"long_direction": "W",
"altitude": 20,
"size": 10,
"precision_horz": 1000,
"precision_vert": 2
},
"meta": {
"auto_added": true,
"source": "primary"
}
}
],
"result_info": {
"page": 3,
"per_page": 10,
"total_pages": 3,
"count": 4,
"total_count": 24
},
"success": true,
"errors": [],
"messages": []
}

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

@ -64,6 +64,62 @@
"roundRobinFailover": [], "roundRobinFailover": [],
"pools": [], "pools": [],
"poolsDetail": [] "poolsDetail": []
}, {
"id": 1898527,
"type": "SRV",
"recordType": "srv",
"name": "_imap._tcp",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149714387,
"value": [{
"value": ".",
"priority": 0,
"weight": 0,
"port": 0,
"disableFlag": false
}],
"roundRobin": [{
"value": ".",
"priority": 0,
"weight": 0,
"port": 0,
"disableFlag": false
}]
}, {
"id": 1898528,
"type": "SRV",
"recordType": "srv",
"name": "_pop3._tcp",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149714387,
"value": [{
"value": ".",
"priority": 0,
"weight": 0,
"port": 0,
"disableFlag": false
}],
"roundRobin": [{
"value": ".",
"priority": 0,
"weight": 0,
"port": 0,
"disableFlag": false
}]
}, { }, {
"id": 1808527, "id": 1808527,
"type": "SRV", "type": "SRV",


+ 22
- 0
tests/fixtures/digitalocean-page-2.json View File

@ -76,6 +76,28 @@
"weight": null, "weight": null,
"flags": null, "flags": null,
"tag": null "tag": null
}, {
"id": 11189896,
"type": "SRV",
"name": "_imap._tcp",
"data": ".",
"priority": 0,
"port": 0,
"ttl": 600,
"weight": 0,
"flags": null,
"tag": null
}, {
"id": 11189897,
"type": "SRV",
"name": "_pop3._tcp",
"data": ".",
"priority": 0,
"port": 0,
"ttl": 600,
"weight": 0,
"flags": null,
"tag": null
}], }],
"links": { "links": {
"pages": { "pages": {


+ 24
- 2
tests/fixtures/easydns-records.json View File

@ -264,10 +264,32 @@
"rdata": "v=DKIM1;k=rsa;s=email;h=sha256;p=A\/kinda+of\/long\/string+with+numb3rs", "rdata": "v=DKIM1;k=rsa;s=email;h=sha256;p=A\/kinda+of\/long\/string+with+numb3rs",
"geozone_id": "0", "geozone_id": "0",
"last_mod": "2020-01-01 01:01:01" "last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340025",
"domain": "unit.tests",
"host": "_imap._tcp",
"ttl": "600",
"prio": "0",
"type": "SRV",
"rdata": "0 0 0 .",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
},
{
"id": "12340026",
"domain": "unit.tests",
"host": "_pop3._tcp",
"ttl": "600",
"prio": "0",
"type": "SRV",
"rdata": "0 0 0 .",
"geozone_id": "0",
"last_mod": "2020-01-01 01:01:01"
} }
], ],
"count": 24,
"total": 24,
"count": 26,
"total": 26,
"start": 0, "start": 0,
"max": 1000, "max": 1000,
"status": 200 "status": 200


+ 18
- 2
tests/fixtures/edgedns-records.json View File

@ -9,6 +9,22 @@
"name": "_srv._tcp.unit.tests", "name": "_srv._tcp.unit.tests",
"ttl": 600 "ttl": 600
}, },
{
"rdata": [
"0 0 0 ."
],
"type": "SRV",
"name": "_imap._tcp.unit.tests",
"ttl": 600
},
{
"rdata": [
"0 0 0 ."
],
"type": "SRV",
"name": "_pop3._tcp.unit.tests",
"ttl": 600
},
{ {
"rdata": [ "rdata": [
"2601:644:500:e210:62f8:1dff:feb8:947a" "2601:644:500:e210:62f8:1dff:feb8:947a"
@ -151,7 +167,7 @@
} }
], ],
"metadata": { "metadata": {
"totalElements": 16,
"totalElements": 18,
"showAll": true "showAll": true
} }
}
}

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

@ -123,6 +123,24 @@
"2.2.3.6" "2.2.3.6"
] ]
}, },
{
"rrset_type": "SRV",
"rrset_ttl": 600,
"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": 600,
"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_type": "SRV",
"rrset_ttl": 600, "rrset_ttl": 600,


+ 2
- 0
tests/fixtures/mythicbeasts-list.txt View File

@ -5,6 +5,8 @@
@ 3600 SSHFP 1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73 @ 3600 SSHFP 1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73
@ 3600 SSHFP 1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49 @ 3600 SSHFP 1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49
@ 3600 CAA 0 issue ca.unit.tests @ 3600 CAA 0 issue ca.unit.tests
_imap._tcp 600 SRV 0 0 0 .
_pop3._tcp 600 SRV 0 0 0 .
_srv._tcp 600 SRV 10 20 30 foo-1.unit.tests. _srv._tcp 600 SRV 10 20 30 foo-1.unit.tests.
_srv._tcp 600 SRV 12 20 30 foo-2.unit.tests. _srv._tcp 600 SRV 12 20 30 foo-2.unit.tests.
aaaa 600 AAAA 2601:644:500:e210:62f8:1dff:feb8:947a aaaa 600 AAAA 2601:644:500:e210:62f8:1dff:feb8:947a


+ 40
- 0
tests/fixtures/powerdns-full-data.json View File

@ -32,6 +32,22 @@
"ttl": 300, "ttl": 300,
"type": "MX" "type": "MX"
}, },
{
"comments": [],
"name": "loc.unit.tests.",
"records": [
{
"content": "31 58 52.100 S 115 49 11.700 E 20.00m 10.00m 10.00m 2.00m",
"disabled": false
},
{
"content": "53 13 10.000 N 2 18 26.000 W 20.00m 10.00m 1000.00m 2.00m",
"disabled": false
}
],
"ttl": 300,
"type": "LOC"
},
{ {
"comments": [], "comments": [],
"name": "sub.unit.tests.", "name": "sub.unit.tests.",
@ -59,6 +75,30 @@
"ttl": 300, "ttl": 300,
"type": "A" "type": "A"
}, },
{
"comments": [],
"name": "_imap._tcp.unit.tests.",
"records": [
{
"content": "0 0 0 .",
"disabled": false
}
],
"ttl": 600,
"type": "SRV"
},
{
"comments": [],
"name": "_pop3._tcp.unit.tests.",
"records": [
{
"content": "0 0 0 .",
"disabled": false
}
],
"ttl": 600,
"type": "SRV"
},
{ {
"comments": [], "comments": [],
"name": "_srv._tcp.unit.tests.", "name": "_srv._tcp.unit.tests.",


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

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

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

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

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

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


+ 17
- 9
tests/test_octodns_manager.py View File

@ -118,12 +118,12 @@ class TestManager(TestCase):
environ['YAML_TMP_DIR'] = tmpdir.dirname environ['YAML_TMP_DIR'] = tmpdir.dirname
tc = Manager(get_config_filename('simple.yaml')) \ tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False) .sync(dry_run=False)
self.assertEquals(22, tc)
self.assertEquals(25, tc)
# try with just one of the zones # try with just one of the zones
tc = Manager(get_config_filename('simple.yaml')) \ tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False, eligible_zones=['unit.tests.']) .sync(dry_run=False, eligible_zones=['unit.tests.'])
self.assertEquals(16, tc)
self.assertEquals(19, tc)
# the subzone, with 2 targets # the subzone, with 2 targets
tc = Manager(get_config_filename('simple.yaml')) \ tc = Manager(get_config_filename('simple.yaml')) \
@ -138,18 +138,18 @@ class TestManager(TestCase):
# Again with force # Again with force
tc = Manager(get_config_filename('simple.yaml')) \ tc = Manager(get_config_filename('simple.yaml')) \
.sync(dry_run=False, force=True) .sync(dry_run=False, force=True)
self.assertEquals(22, tc)
self.assertEquals(25, tc)
# Again with max_workers = 1 # Again with max_workers = 1
tc = Manager(get_config_filename('simple.yaml'), max_workers=1) \ tc = Manager(get_config_filename('simple.yaml'), max_workers=1) \
.sync(dry_run=False, force=True) .sync(dry_run=False, force=True)
self.assertEquals(22, tc)
self.assertEquals(25, tc)
# Include meta # Include meta
tc = Manager(get_config_filename('simple.yaml'), max_workers=1, tc = Manager(get_config_filename('simple.yaml'), max_workers=1,
include_meta=True) \ include_meta=True) \
.sync(dry_run=False, force=True) .sync(dry_run=False, force=True)
self.assertEquals(26, tc)
self.assertEquals(29, tc)
def test_eligible_sources(self): def test_eligible_sources(self):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
@ -180,7 +180,7 @@ class TestManager(TestCase):
tc = Manager(get_config_filename('unknown-source-zone.yaml')) \ tc = Manager(get_config_filename('unknown-source-zone.yaml')) \
.sync() .sync()
self.assertEquals('Invalid alias zone alias.tests.: source zone ' self.assertEquals('Invalid alias zone alias.tests.: source zone '
'does-not-exists.tests. does not exist',
'does-not-exists.tests. does not exist',
text_type(ctx.exception)) text_type(ctx.exception))
# Alias zone that points to another alias zone. # Alias zone that points to another alias zone.
@ -188,7 +188,15 @@ class TestManager(TestCase):
tc = Manager(get_config_filename('alias-zone-loop.yaml')) \ tc = Manager(get_config_filename('alias-zone-loop.yaml')) \
.sync() .sync()
self.assertEquals('Invalid alias zone alias-loop.tests.: source ' self.assertEquals('Invalid alias zone alias-loop.tests.: source '
'zone alias.tests. is an alias zone',
'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)) text_type(ctx.exception))
def test_compare(self): def test_compare(self):
@ -207,13 +215,13 @@ class TestManager(TestCase):
fh.write('---\n{}') fh.write('---\n{}')
changes = manager.compare(['in'], ['dump'], 'unit.tests.') changes = manager.compare(['in'], ['dump'], 'unit.tests.')
self.assertEquals(16, len(changes))
self.assertEquals(19, len(changes))
# Compound sources with varying support # Compound sources with varying support
changes = manager.compare(['in', 'nosshfp'], changes = manager.compare(['in', 'nosshfp'],
['dump'], ['dump'],
'unit.tests.') 'unit.tests.')
self.assertEquals(15, len(changes))
self.assertEquals(18, len(changes))
with self.assertRaises(ManagerException) as ctx: with self.assertRaises(ManagerException) as ctx:
manager.compare(['nope'], ['dump'], 'unit.tests.') manager.compare(['nope'], ['dump'], 'unit.tests.')


+ 91
- 18
tests/test_octodns_provider_azuredns.py View File

@ -7,13 +7,13 @@ from __future__ import absolute_import, division, print_function, \
from octodns.record import Create, Delete, Record from octodns.record import Create, Delete, Record
from octodns.provider.azuredns import _AzureRecord, AzureProvider, \ from octodns.provider.azuredns import _AzureRecord, AzureProvider, \
_check_endswith_dot, _parse_azure_type
_check_endswith_dot, _parse_azure_type, _check_for_alias
from octodns.zone import Zone from octodns.zone import Zone
from octodns.provider.base import Plan from octodns.provider.base import Plan
from azure.mgmt.dns.models import ARecord, AaaaRecord, CaaRecord, \ from azure.mgmt.dns.models import ARecord, AaaaRecord, CaaRecord, \
CnameRecord, MxRecord, SrvRecord, NsRecord, PtrRecord, TxtRecord, \ CnameRecord, MxRecord, SrvRecord, NsRecord, PtrRecord, TxtRecord, \
RecordSet, SoaRecord, Zone as AzureZone
RecordSet, SoaRecord, SubResource, Zone as AzureZone
from msrestazure.azure_exceptions import CloudError from msrestazure.azure_exceptions import CloudError
from unittest import TestCase from unittest import TestCase
@ -134,6 +134,18 @@ octo_records.append(Record.new(zone, 'txt2', {
'type': 'TXT', 'type': 'TXT',
'values': ['txt multiple test', 'txt multiple test 2']})) 'values': ['txt multiple test', 'txt multiple test 2']}))
long_txt = "v=spf1 ip4:10.10.0.0/24 ip4:10.10.1.0/24 ip4:10.10.2.0/24"
long_txt += " ip4:10.10.3.0/24 ip4:10.10.4.0/24 ip4:10.10.5.0/24 "
long_txt += " 10.6.0/24 ip4:10.10.7.0/24 ip4:10.10.8.0/24 "
long_txt += " ip4:10.10.10.0/24 ip4:10.10.11.0/24 ip4:10.10.12.0/24"
long_txt += " ip4:10.10.13.0/24 ip4:10.10.14.0/24 ip4:10.10.15.0/24"
long_txt += " ip4:10.10.16.0/24 ip4:10.10.17.0/24 ip4:10.10.18.0/24"
long_txt += " ip4:10.10.19.0/24 ip4:10.10.20.0/24 ~all"
octo_records.append(Record.new(zone, 'txt3', {
'ttl': 10,
'type': 'TXT',
'values': ['txt multiple test', long_txt]}))
azure_records = [] azure_records = []
_base0 = _AzureRecord('TestAzure', octo_records[0]) _base0 = _AzureRecord('TestAzure', octo_records[0])
_base0.zone_name = 'unit.tests' _base0.zone_name = 'unit.tests'
@ -306,6 +318,22 @@ _base17.params['txt_records'] = [TxtRecord(value=['txt multiple test']),
TxtRecord(value=['txt multiple test 2'])] TxtRecord(value=['txt multiple test 2'])]
azure_records.append(_base17) azure_records.append(_base17)
long_txt_az1 = "v=spf1 ip4:10.10.0.0/24 ip4:10.10.1.0/24 ip4:10.10.2.0/24"
long_txt_az1 += " ip4:10.10.3.0/24 ip4:10.10.4.0/24 ip4:10.10.5.0/24 "
long_txt_az1 += " 10.6.0/24 ip4:10.10.7.0/24 ip4:10.10.8.0/24 "
long_txt_az1 += " ip4:10.10.10.0/24 ip4:10.10.11.0/24 ip4:10.10.12.0/24"
long_txt_az1 += " ip4:10.10.13.0/24 ip4:10.10.14.0/24 ip4:10.10."
long_txt_az2 = "15.0/24 ip4:10.10.16.0/24 ip4:10.10.17.0/24 ip4:10.10.18.0/24"
long_txt_az2 += " ip4:10.10.19.0/24 ip4:10.10.20.0/24 ~all"
_base18 = _AzureRecord('TestAzure', octo_records[18])
_base18.zone_name = 'unit.tests'
_base18.relative_record_set_name = 'txt3'
_base18.record_type = 'TXT'
_base18.params['ttl'] = 10
_base18.params['txt_records'] = [TxtRecord(value=['txt multiple test']),
TxtRecord(value=[long_txt_az1, long_txt_az2])]
azure_records.append(_base18)
class Test_AzureRecord(TestCase): class Test_AzureRecord(TestCase):
def test_azure_record(self): def test_azure_record(self):
@ -333,6 +361,17 @@ class Test_CheckEndswithDot(TestCase):
self.assertEquals(expected, _check_endswith_dot(test)) self.assertEquals(expected, _check_endswith_dot(test))
class Test_CheckAzureAlias(TestCase):
def test_check_for_alias(self):
alias_record = type('C', (object,), {})
alias_record.target_resource = type('C', (object,), {})
alias_record.target_resource.id = "/subscriptions/x/resourceGroups/y/z"
alias_record.arecords = None
alias_record.cname_record = None
self.assertEquals(_check_for_alias(alias_record), True)
class TestAzureDnsProvider(TestCase): class TestAzureDnsProvider(TestCase):
def _provider(self): def _provider(self):
return self._get_provider('mock_spc', 'mock_dns_client') return self._get_provider('mock_spc', 'mock_dns_client')
@ -349,8 +388,12 @@ class TestAzureDnsProvider(TestCase):
:type return: AzureProvider :type return: AzureProvider
''' '''
return AzureProvider('mock_id', 'mock_client', 'mock_key',
'mock_directory', 'mock_sub', 'mock_rg')
provider = AzureProvider('mock_id', 'mock_client', 'mock_key',
'mock_directory', 'mock_sub', 'mock_rg'
)
# Fetch the client to force it to load the creds
provider._dns_client
return provider
def test_populate_records(self): def test_populate_records(self):
provider = self._get_provider() provider = self._get_provider()
@ -358,19 +401,23 @@ class TestAzureDnsProvider(TestCase):
rs = [] rs = []
recordSet = RecordSet(arecords=[ARecord(ipv4_address='1.1.1.1')]) recordSet = RecordSet(arecords=[ARecord(ipv4_address='1.1.1.1')])
recordSet.name, recordSet.ttl, recordSet.type = 'a1', 0, 'A' recordSet.name, recordSet.ttl, recordSet.type = 'a1', 0, 'A'
recordSet.target_resource = SubResource()
rs.append(recordSet) rs.append(recordSet)
recordSet = RecordSet(arecords=[ARecord(ipv4_address='1.1.1.1'), recordSet = RecordSet(arecords=[ARecord(ipv4_address='1.1.1.1'),
ARecord(ipv4_address='2.2.2.2')]) ARecord(ipv4_address='2.2.2.2')])
recordSet.name, recordSet.ttl, recordSet.type = 'a2', 1, 'A' recordSet.name, recordSet.ttl, recordSet.type = 'a2', 1, 'A'
recordSet.target_resource = SubResource()
rs.append(recordSet) rs.append(recordSet)
aaaa1 = AaaaRecord(ipv6_address='1:1ec:1::1') aaaa1 = AaaaRecord(ipv6_address='1:1ec:1::1')
recordSet = RecordSet(aaaa_records=[aaaa1]) recordSet = RecordSet(aaaa_records=[aaaa1])
recordSet.name, recordSet.ttl, recordSet.type = 'aaaa1', 2, 'AAAA' recordSet.name, recordSet.ttl, recordSet.type = 'aaaa1', 2, 'AAAA'
recordSet.target_resource = SubResource()
rs.append(recordSet) rs.append(recordSet)
aaaa2 = AaaaRecord(ipv6_address='1:1ec:1::2') aaaa2 = AaaaRecord(ipv6_address='1:1ec:1::2')
recordSet = RecordSet(aaaa_records=[aaaa1, recordSet = RecordSet(aaaa_records=[aaaa1,
aaaa2]) aaaa2])
recordSet.name, recordSet.ttl, recordSet.type = 'aaaa2', 3, 'AAAA' recordSet.name, recordSet.ttl, recordSet.type = 'aaaa2', 3, 'AAAA'
recordSet.target_resource = SubResource()
rs.append(recordSet) rs.append(recordSet)
recordSet = RecordSet(caa_records=[CaaRecord(flags=0, recordSet = RecordSet(caa_records=[CaaRecord(flags=0,
tag='issue', tag='issue',
@ -388,6 +435,7 @@ class TestAzureDnsProvider(TestCase):
cname1 = CnameRecord(cname='cname.unit.test.') cname1 = CnameRecord(cname='cname.unit.test.')
recordSet = RecordSet(cname_record=cname1) recordSet = RecordSet(cname_record=cname1)
recordSet.name, recordSet.ttl, recordSet.type = 'cname1', 5, 'CNAME' recordSet.name, recordSet.ttl, recordSet.type = 'cname1', 5, 'CNAME'
recordSet.target_resource = SubResource()
rs.append(recordSet) rs.append(recordSet)
recordSet = RecordSet(mx_records=[MxRecord(preference=10, recordSet = RecordSet(mx_records=[MxRecord(preference=10,
exchange='mx1.unit.test.')]) exchange='mx1.unit.test.')])
@ -428,45 +476,70 @@ class TestAzureDnsProvider(TestCase):
rs.append(recordSet) rs.append(recordSet)
recordSet = RecordSet(txt_records=[TxtRecord(value='sample text1')]) recordSet = RecordSet(txt_records=[TxtRecord(value='sample text1')])
recordSet.name, recordSet.ttl, recordSet.type = 'txt1', 15, 'TXT' recordSet.name, recordSet.ttl, recordSet.type = 'txt1', 15, 'TXT'
recordSet.target_resource = SubResource()
rs.append(recordSet) rs.append(recordSet)
recordSet = RecordSet(txt_records=[TxtRecord(value='sample text1'), recordSet = RecordSet(txt_records=[TxtRecord(value='sample text1'),
TxtRecord(value='sample text2')]) TxtRecord(value='sample text2')])
recordSet.name, recordSet.ttl, recordSet.type = 'txt2', 16, 'TXT' recordSet.name, recordSet.ttl, recordSet.type = 'txt2', 16, 'TXT'
recordSet.target_resource = SubResource()
rs.append(recordSet) rs.append(recordSet)
recordSet = RecordSet(soa_record=[SoaRecord()]) recordSet = RecordSet(soa_record=[SoaRecord()])
recordSet.name, recordSet.ttl, recordSet.type = '', 17, 'SOA' recordSet.name, recordSet.ttl, recordSet.type = '', 17, 'SOA'
rs.append(recordSet) rs.append(recordSet)
long_txt = "v=spf1 ip4:10.10.0.0/24 ip4:10.10.1.0/24 ip4:10.10.2.0/24"
long_txt += " ip4:10.10.3.0/24 ip4:10.10.4.0/24 ip4:10.10.5.0/24 "
long_txt += " 10.6.0/24 ip4:10.10.7.0/24 ip4:10.10.8.0/24 "
long_txt += " ip4:10.10.10.0/24 ip4:10.10.11.0/24 ip4:10.10.12.0/24"
long_txt += " ip4:10.10.13.0/24 ip4:10.10.14.0/24 ip4:10.10.15.0/24"
long_txt += " ip4:10.10.16.0/24 ip4:10.10.17.0/24 ip4:10.10.18.0/24"
long_txt += " ip4:10.10.19.0/24 ip4:10.10.20.0/24 ~all"
recordSet = RecordSet(txt_records=[TxtRecord(value='sample value1'),
TxtRecord(value=long_txt)])
recordSet.name, recordSet.ttl, recordSet.type = 'txt3', 18, 'TXT'
recordSet.target_resource = SubResource()
rs.append(recordSet)
record_list = provider._dns_client.record_sets.list_by_dns_zone record_list = provider._dns_client.record_sets.list_by_dns_zone
record_list.return_value = rs record_list.return_value = rs
zone_list = provider._dns_client.zones.list_by_resource_group
zone_list.return_value = [zone]
exists = provider.populate(zone) exists = provider.populate(zone)
self.assertTrue(exists)
self.assertEquals(len(zone.records), 16)
self.assertEquals(len(zone.records), 17)
self.assertTrue(exists)
def test_populate_zone(self): def test_populate_zone(self):
provider = self._get_provider() provider = self._get_provider()
zone_list = provider._dns_client.zones.list_by_resource_group zone_list = provider._dns_client.zones.list_by_resource_group
zone_list.return_value = [AzureZone(location='global'),
AzureZone(location='global')]
zone_1 = AzureZone(location='global')
# This is far from ideal but the
# zone constructor doesn't let me set it on creation
zone_1.name = "zone-1"
zone_2 = AzureZone(location='global')
# This is far from ideal but the
# zone constructor doesn't let me set it on creation
zone_2.name = "zone-2"
zone_list.return_value = [zone_1,
zone_2,
zone_1]
provider._populate_zones() provider._populate_zones()
self.assertEquals(len(provider._azure_zones), 1)
# This should be returning two zones since two zones are the same
self.assertEquals(len(provider._azure_zones), 2)
def test_bad_zone_response(self): def test_bad_zone_response(self):
provider = self._get_provider() provider = self._get_provider()
_get = provider._dns_client.zones.get _get = provider._dns_client.zones.get
_get.side_effect = CloudError(Mock(status=404), 'Azure Error') _get.side_effect = CloudError(Mock(status=404), 'Azure Error')
trip = False
try:
provider._check_zone('unit.test', create=False)
except CloudError:
trip = True
self.assertEquals(trip, True)
self.assertEquals(
provider._check_zone('unit.test', create=False),
None
)
def test_apply(self): def test_apply(self):
provider = self._get_provider() provider = self._get_provider()
@ -477,9 +550,9 @@ class TestAzureDnsProvider(TestCase):
changes.append(Create(i)) changes.append(Create(i))
deletes.append(Delete(i)) deletes.append(Delete(i))
self.assertEquals(18, provider.apply(Plan(None, zone,
self.assertEquals(19, provider.apply(Plan(None, zone,
changes, True))) changes, True)))
self.assertEquals(18, provider.apply(Plan(zone, zone,
self.assertEquals(19, provider.apply(Plan(zone, zone,
deletes, True))) deletes, True)))
def test_create_zone(self): def test_create_zone(self):
@ -495,7 +568,7 @@ class TestAzureDnsProvider(TestCase):
_get = provider._dns_client.zones.get _get = provider._dns_client.zones.get
_get.side_effect = CloudError(Mock(status=404), err_msg) _get.side_effect = CloudError(Mock(status=404), err_msg)
self.assertEquals(18, provider.apply(Plan(None, desired, changes,
self.assertEquals(19, provider.apply(Plan(None, desired, changes,
True))) True)))
def test_check_zone_no_create(self): def test_check_zone_no_create(self):


+ 78
- 11
tests/test_octodns_provider_cloudflare.py View File

@ -177,10 +177,14 @@ class TestCloudflareProvider(TestCase):
'page-2.json') as fh: 'page-2.json') as fh:
mock.get('{}?page=2'.format(base), status_code=200, mock.get('{}?page=2'.format(base), status_code=200,
text=fh.read()) text=fh.read())
with open('tests/fixtures/cloudflare-dns_records-'
'page-3.json') as fh:
mock.get('{}?page=3'.format(base), status_code=200,
text=fh.read())
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(13, len(zone.records))
self.assertEquals(16, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
@ -189,7 +193,7 @@ class TestCloudflareProvider(TestCase):
# re-populating the same zone/records comes out of cache, no calls # re-populating the same zone/records comes out of cache, no calls
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(13, len(again.records))
self.assertEquals(16, len(again.records))
def test_apply(self): def test_apply(self):
provider = CloudflareProvider('test', 'email', 'token', retry_period=0) provider = CloudflareProvider('test', 'email', 'token', retry_period=0)
@ -203,12 +207,12 @@ class TestCloudflareProvider(TestCase):
'id': 42, 'id': 42,
} }
}, # zone create }, # zone create
] + [None] * 22 # individual record creates
] + [None] * 25 # individual record creates
# non-existent zone, create everything # non-existent zone, create everything
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
self.assertEquals(13, len(plan.changes))
self.assertEquals(13, provider.apply(plan))
self.assertEquals(16, len(plan.changes))
self.assertEquals(16, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
provider._request.assert_has_calls([ provider._request.assert_has_calls([
@ -234,7 +238,7 @@ class TestCloudflareProvider(TestCase):
}), }),
], True) ], True)
# expected number of total calls # expected number of total calls
self.assertEquals(23, provider._request.call_count)
self.assertEquals(27, provider._request.call_count)
provider._request.reset_mock() provider._request.reset_mock()
@ -336,6 +340,10 @@ class TestCloudflareProvider(TestCase):
self.assertTrue(plan.exists) self.assertTrue(plan.exists)
# creates a the new value and then deletes all the old # creates a the new value and then deletes all the old
provider._request.assert_has_calls([ provider._request.assert_has_calls([
call('DELETE', '/zones/ff12ab34cd5611334422ab3322997650/'
'dns_records/fc12ab34cd5611334422ab3322997653'),
call('DELETE', '/zones/ff12ab34cd5611334422ab3322997650/'
'dns_records/fc12ab34cd5611334422ab3322997654'),
call('PUT', '/zones/42/dns_records/' call('PUT', '/zones/42/dns_records/'
'fc12ab34cd5611334422ab3322997655', data={ 'fc12ab34cd5611334422ab3322997655', data={
'content': '3.2.3.4', 'content': '3.2.3.4',
@ -343,11 +351,7 @@ class TestCloudflareProvider(TestCase):
'name': 'ttl.unit.tests', 'name': 'ttl.unit.tests',
'proxied': False, 'proxied': False,
'ttl': 300 'ttl': 300
}),
call('DELETE', '/zones/ff12ab34cd5611334422ab3322997650/'
'dns_records/fc12ab34cd5611334422ab3322997653'),
call('DELETE', '/zones/ff12ab34cd5611334422ab3322997650/'
'dns_records/fc12ab34cd5611334422ab3322997654')
})
]) ])
def test_update_add_swap(self): def test_update_add_swap(self):
@ -566,6 +570,52 @@ class TestCloudflareProvider(TestCase):
'content': 'foo.bar.com.' 'content': 'foo.bar.com.'
}, list(ptr_record_contents)[0]) }, list(ptr_record_contents)[0])
def test_loc(self):
self.maxDiff = None
provider = CloudflareProvider('test', 'email', 'token')
zone = Zone('unit.tests.', [])
# LOC record
loc_record = Record.new(zone, 'example', {
'ttl': 300,
'type': 'LOC',
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
loc_record_contents = provider._gen_data(loc_record)
self.assertEquals({
'name': 'example.unit.tests',
'ttl': 300,
'type': 'LOC',
'data': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
}, list(loc_record_contents)[0])
def test_srv(self): def test_srv(self):
provider = CloudflareProvider('test', 'email', 'token') provider = CloudflareProvider('test', 'email', 'token')
@ -697,6 +747,23 @@ class TestCloudflareProvider(TestCase):
}, },
'type': 'SRV', 'type': 'SRV',
}), }),
('31 58 52.1 S 115 49 11.7 E 20 10 10 2', {
'data': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
},
'type': 'LOC',
}),
): ):
self.assertEqual(expected, provider._gen_key(data)) self.assertEqual(expected, provider._gen_key(data))


+ 4
- 4
tests/test_octodns_provider_constellix.py View File

@ -101,14 +101,14 @@ class TestConstellixProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(14, len(zone.records))
self.assertEquals(16, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache # 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(14, len(again.records))
self.assertEquals(16, len(again.records))
# bust the cache # bust the cache
del provider._zone_records[zone.name] del provider._zone_records[zone.name]
@ -132,7 +132,7 @@ class TestConstellixProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported # No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 6
n = len(self.expected.records) - 7
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
@ -163,7 +163,7 @@ class TestConstellixProvider(TestCase):
}), }),
]) ])
self.assertEquals(17, provider._client._request.call_count)
self.assertEquals(19, provider._client._request.call_count)
provider._client._request.reset_mock() provider._client._request.reset_mock()


+ 22
- 4
tests/test_octodns_provider_digitalocean.py View File

@ -83,14 +83,14 @@ class TestDigitalOceanProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(12, len(zone.records))
self.assertEquals(14, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache # 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(12, len(again.records))
self.assertEquals(14, len(again.records))
# bust the cache # bust the cache
del provider._zone_records[zone.name] del provider._zone_records[zone.name]
@ -163,7 +163,7 @@ class TestDigitalOceanProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported # No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 8
n = len(self.expected.records) - 9
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
@ -190,6 +190,24 @@ class TestDigitalOceanProvider(TestCase):
'flags': 0, 'name': '@', 'flags': 0, 'name': '@',
'tag': 'issue', 'tag': 'issue',
'ttl': 3600, 'type': 'CAA'}), 'ttl': 3600, 'type': 'CAA'}),
call('POST', '/domains/unit.tests/records', data={
'name': '_imap._tcp',
'weight': 0,
'data': '.',
'priority': 0,
'ttl': 600,
'type': 'SRV',
'port': 0
}),
call('POST', '/domains/unit.tests/records', data={
'name': '_pop3._tcp',
'weight': 0,
'data': '.',
'priority': 0,
'ttl': 600,
'type': 'SRV',
'port': 0
}),
call('POST', '/domains/unit.tests/records', data={ call('POST', '/domains/unit.tests/records', data={
'name': '_srv._tcp', 'name': '_srv._tcp',
'weight': 20, 'weight': 20,
@ -200,7 +218,7 @@ class TestDigitalOceanProvider(TestCase):
'port': 30 'port': 30
}), }),
]) ])
self.assertEquals(24, provider._client._request.call_count)
self.assertEquals(26, provider._client._request.call_count)
provider._client._request.reset_mock() provider._client._request.reset_mock()


+ 1
- 1
tests/test_octodns_provider_dnsimple.py View File

@ -137,7 +137,7 @@ class TestDnsimpleProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded # No root NS, no ignored, no excluded
n = len(self.expected.records) - 4
n = len(self.expected.records) - 7
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)


+ 1
- 1
tests/test_octodns_provider_dnsmadeeasy.py View File

@ -134,7 +134,7 @@ class TestDnsMadeEasyProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported # No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 6
n = len(self.expected.records) - 9
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))


+ 4
- 4
tests/test_octodns_provider_easydns.py View File

@ -80,14 +80,14 @@ class TestEasyDNSProvider(TestCase):
text=fh.read()) text=fh.read())
provider.populate(zone) provider.populate(zone)
self.assertEquals(13, len(zone.records))
self.assertEquals(15, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache # 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(13, len(again.records))
self.assertEquals(15, len(again.records))
# bust the cache # bust the cache
del provider._zone_records[zone.name] del provider._zone_records[zone.name]
@ -374,12 +374,12 @@ class TestEasyDNSProvider(TestCase):
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported # No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 7
n = len(self.expected.records) - 8
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
self.assertEquals(23, provider._client._request.call_count)
self.assertEquals(25, provider._client._request.call_count)
provider._client._request.reset_mock() provider._client._request.reset_mock()


+ 5
- 5
tests/test_octodns_provider_edgedns.py View File

@ -77,14 +77,14 @@ class TestEdgeDnsProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(16, len(zone.records))
self.assertEquals(18, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache # 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(16, len(again.records))
self.assertEquals(18, len(again.records))
# bust the cache # bust the cache
del provider._zone_records[zone.name] del provider._zone_records[zone.name]
@ -105,7 +105,7 @@ class TestEdgeDnsProvider(TestCase):
mock.delete(ANY, status_code=204) mock.delete(ANY, status_code=204)
changes = provider.apply(plan) changes = provider.apply(plan)
self.assertEquals(29, changes)
self.assertEquals(31, changes)
# Test against a zone that doesn't exist yet # Test against a zone that doesn't exist yet
with requests_mock() as mock: with requests_mock() as mock:
@ -118,7 +118,7 @@ class TestEdgeDnsProvider(TestCase):
mock.delete(ANY, status_code=204) mock.delete(ANY, status_code=204)
changes = provider.apply(plan) changes = provider.apply(plan)
self.assertEquals(14, changes)
self.assertEquals(16, changes)
# Test against a zone that doesn't exist yet, but gid not provided # Test against a zone that doesn't exist yet, but gid not provided
with requests_mock() as mock: with requests_mock() as mock:
@ -132,7 +132,7 @@ class TestEdgeDnsProvider(TestCase):
mock.delete(ANY, status_code=204) mock.delete(ANY, status_code=204)
changes = provider.apply(plan) changes = provider.apply(plan)
self.assertEquals(14, changes)
self.assertEquals(16, changes)
# Test against a zone that doesn't exist, but cid not provided # Test against a zone that doesn't exist, but cid not provided


+ 21
- 5
tests/test_octodns_provider_gandi.py View File

@ -117,7 +117,7 @@ class TestGandiProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(14, len(zone.records))
self.assertEquals(16, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
@ -174,7 +174,7 @@ class TestGandiProvider(TestCase):
GandiClientUnknownDomainName)) as ctx: GandiClientUnknownDomainName)) as ctx:
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
provider.apply(plan) provider.apply(plan)
self.assertIn('This domain is not registred at Gandi.',
self.assertIn('This domain is not registered at Gandi.',
text_type(ctx.exception)) text_type(ctx.exception))
resp = Mock() resp = Mock()
@ -192,8 +192,8 @@ class TestGandiProvider(TestCase):
] ]
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded
n = len(self.expected.records) - 4
# No root NS, no ignored, no excluded, no LOC
n = len(self.expected.records) - 5
self.assertEquals(n, len(plan.changes)) self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan)) self.assertEquals(n, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
@ -284,6 +284,22 @@ class TestGandiProvider(TestCase):
'12 20 30 foo-2.unit.tests.' '12 20 30 foo-2.unit.tests.'
] ]
}), }),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': '_pop3._tcp',
'rrset_ttl': 600,
'rrset_type': 'SRV',
'rrset_values': [
'0 0 0 .',
]
}),
call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': '_imap._tcp',
'rrset_ttl': 600,
'rrset_type': 'SRV',
'rrset_values': [
'0 0 0 .',
]
}),
call('POST', '/livedns/domains/unit.tests/records', data={ call('POST', '/livedns/domains/unit.tests/records', data={
'rrset_name': '@', 'rrset_name': '@',
'rrset_ttl': 3600, 'rrset_ttl': 3600,
@ -307,7 +323,7 @@ class TestGandiProvider(TestCase):
}) })
]) ])
# expected number of total calls # expected number of total calls
self.assertEquals(17, provider._client._request.call_count)
self.assertEquals(19, provider._client._request.call_count)
provider._client._request.reset_mock() provider._client._request.reset_mock()


+ 4
- 4
tests/test_octodns_provider_mythicbeasts.py View File

@ -378,8 +378,8 @@ class TestMythicBeastsProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(15, len(zone.records))
self.assertEquals(15, len(self.expected.records))
self.assertEquals(17, len(zone.records))
self.assertEquals(17, len(self.expected.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
@ -445,7 +445,7 @@ class TestMythicBeastsProvider(TestCase):
if isinstance(c, Update)])) if isinstance(c, Update)]))
self.assertEquals(1, len([c for c in plan.changes self.assertEquals(1, len([c for c in plan.changes
if isinstance(c, Delete)])) if isinstance(c, Delete)]))
self.assertEquals(14, len([c for c in plan.changes
self.assertEquals(16, len([c for c in plan.changes
if isinstance(c, Create)])) if isinstance(c, Create)]))
self.assertEquals(16, provider.apply(plan))
self.assertEquals(18, provider.apply(plan))
self.assertTrue(plan.exists) self.assertTrue(plan.exists)

+ 3
- 3
tests/test_octodns_provider_powerdns.py View File

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


+ 2
- 2
tests/test_octodns_provider_transip.py View File

@ -222,7 +222,7 @@ N4OiVz1I3rbZGYa396lpxO6ku8yCglisL1yrSP6DdEUp66ntpKVd
provider._client = MockDomainService('unittest', self.bogus_key) provider._client = MockDomainService('unittest', self.bogus_key)
plan = provider.plan(_expected) plan = provider.plan(_expected)
self.assertEqual(12, plan.change_counts['Create'])
self.assertEqual(14, plan.change_counts['Create'])
self.assertEqual(0, plan.change_counts['Update']) self.assertEqual(0, plan.change_counts['Update'])
self.assertEqual(0, plan.change_counts['Delete']) self.assertEqual(0, plan.change_counts['Delete'])
@ -235,7 +235,7 @@ N4OiVz1I3rbZGYa396lpxO6ku8yCglisL1yrSP6DdEUp66ntpKVd
provider = TransipProvider('test', 'unittest', self.bogus_key) provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key) provider._client = MockDomainService('unittest', self.bogus_key)
plan = provider.plan(_expected) plan = provider.plan(_expected)
self.assertEqual(12, len(plan.changes))
self.assertEqual(14, len(plan.changes))
changes = provider.apply(plan) changes = provider.apply(plan)
self.assertEqual(changes, len(plan.changes)) self.assertEqual(changes, len(plan.changes))


+ 4
- 4
tests/test_octodns_provider_ultra.py View File

@ -285,12 +285,12 @@ class TestUltraProvider(TestCase):
provider._request.side_effect = [ provider._request.side_effect = [
UltraNoZonesExistException('No Zones'), UltraNoZonesExistException('No Zones'),
None, # zone create None, # zone create
] + [None] * 13 # individual record creates
] + [None] * 15 # individual record creates
# non-existent zone, create everything # non-existent zone, create everything
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
self.assertEquals(13, len(plan.changes))
self.assertEquals(13, provider.apply(plan))
self.assertEquals(15, len(plan.changes))
self.assertEquals(15, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
provider._request.assert_has_calls([ provider._request.assert_has_calls([
@ -320,7 +320,7 @@ class TestUltraProvider(TestCase):
'p=A/kinda+of/long/string+with+numb3rs']}), 'p=A/kinda+of/long/string+with+numb3rs']}),
], True) ], True)
# expected number of total calls # expected number of total calls
self.assertEquals(15, provider._request.call_count)
self.assertEquals(17, provider._request.call_count)
# Create sample rrset payload to attempt to alter # Create sample rrset payload to attempt to alter
page1 = json_load(open('tests/fixtures/ultra-records-page-1.json')) page1 = json_load(open('tests/fixtures/ultra-records-page-1.json'))


+ 26
- 16
tests/test_octodns_provider_yaml.py View File

@ -35,7 +35,7 @@ class TestYamlProvider(TestCase):
# without it we see everything # without it we see everything
source.populate(zone) source.populate(zone)
self.assertEquals(19, len(zone.records))
self.assertEquals(22, len(zone.records))
source.populate(dynamic_zone) source.populate(dynamic_zone)
self.assertEquals(5, len(dynamic_zone.records)) self.assertEquals(5, len(dynamic_zone.records))
@ -58,12 +58,12 @@ class TestYamlProvider(TestCase):
# We add everything # We add everything
plan = target.plan(zone) plan = target.plan(zone)
self.assertEquals(16, len([c for c in plan.changes
self.assertEquals(19, len([c for c in plan.changes
if isinstance(c, Create)])) if isinstance(c, Create)]))
self.assertFalse(isfile(yaml_file)) self.assertFalse(isfile(yaml_file))
# Now actually do it # Now actually do it
self.assertEquals(16, target.apply(plan))
self.assertEquals(19, target.apply(plan))
self.assertTrue(isfile(yaml_file)) self.assertTrue(isfile(yaml_file))
# Dynamic plan # Dynamic plan
@ -87,7 +87,7 @@ class TestYamlProvider(TestCase):
# A 2nd sync should still create everything # A 2nd sync should still create everything
plan = target.plan(zone) plan = target.plan(zone)
self.assertEquals(16, len([c for c in plan.changes
self.assertEquals(19, len([c for c in plan.changes
if isinstance(c, Create)])) if isinstance(c, Create)]))
with open(yaml_file) as fh: with open(yaml_file) as fh:
@ -106,7 +106,10 @@ class TestYamlProvider(TestCase):
self.assertTrue('values' in data.pop('naptr')) self.assertTrue('values' in data.pop('naptr'))
self.assertTrue('values' in data.pop('sub')) self.assertTrue('values' in data.pop('sub'))
self.assertTrue('values' in data.pop('txt')) self.assertTrue('values' in data.pop('txt'))
self.assertTrue('values' in data.pop('loc'))
# these are stored as singular 'value' # these are stored as singular 'value'
self.assertTrue('value' in data.pop('_imap._tcp'))
self.assertTrue('value' in data.pop('_pop3._tcp'))
self.assertTrue('value' in data.pop('aaaa')) self.assertTrue('value' in data.pop('aaaa'))
self.assertTrue('value' in data.pop('cname')) self.assertTrue('value' in data.pop('cname'))
self.assertTrue('value' in data.pop('dname')) self.assertTrue('value' in data.pop('dname'))
@ -207,18 +210,20 @@ class TestSplitYamlProvider(TestCase):
def test_zone_directory(self): def test_zone_directory(self):
source = SplitYamlProvider( source = SplitYamlProvider(
'test', join(dirname(__file__), 'config/split'))
'test', join(dirname(__file__), 'config/split'),
extension='.tst')
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
self.assertEqual( self.assertEqual(
join(dirname(__file__), 'config/split/unit.tests.'),
join(dirname(__file__), 'config/split', 'unit.tests.tst'),
source._zone_directory(zone)) source._zone_directory(zone))
def test_apply_handles_existing_zone_directory(self): def test_apply_handles_existing_zone_directory(self):
with TemporaryDirectory() as td: with TemporaryDirectory() as td:
provider = SplitYamlProvider('test', join(td.dirname, 'config'))
makedirs(join(td.dirname, 'config', 'does.exist.'))
provider = SplitYamlProvider('test', join(td.dirname, 'config'),
extension='.tst')
makedirs(join(td.dirname, 'config', 'does.exist.tst'))
zone = Zone('does.exist.', []) zone = Zone('does.exist.', [])
self.assertTrue(isdir(provider._zone_directory(zone))) self.assertTrue(isdir(provider._zone_directory(zone)))
@ -227,7 +232,8 @@ class TestSplitYamlProvider(TestCase):
def test_provider(self): def test_provider(self):
source = SplitYamlProvider( source = SplitYamlProvider(
'test', join(dirname(__file__), 'config/split'))
'test', join(dirname(__file__), 'config/split'),
extension='.tst')
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
dynamic_zone = Zone('dynamic.tests.', []) dynamic_zone = Zone('dynamic.tests.', [])
@ -246,9 +252,10 @@ class TestSplitYamlProvider(TestCase):
with TemporaryDirectory() as td: with TemporaryDirectory() as td:
# Add some subdirs to make sure that it can create them # Add some subdirs to make sure that it can create them
directory = join(td.dirname, 'sub', 'dir') directory = join(td.dirname, 'sub', 'dir')
zone_dir = join(directory, 'unit.tests.')
dynamic_zone_dir = join(directory, 'dynamic.tests.')
target = SplitYamlProvider('test', directory)
zone_dir = join(directory, 'unit.tests.tst')
dynamic_zone_dir = join(directory, 'dynamic.tests.tst')
target = SplitYamlProvider('test', directory,
extension='.tst')
# We add everything # We add everything
plan = target.plan(zone) plan = target.plan(zone)
@ -335,7 +342,8 @@ class TestSplitYamlProvider(TestCase):
def test_empty(self): def test_empty(self):
source = SplitYamlProvider( source = SplitYamlProvider(
'test', join(dirname(__file__), 'config/split'))
'test', join(dirname(__file__), 'config/split'),
extension='.tst')
zone = Zone('empty.', []) zone = Zone('empty.', [])
@ -345,7 +353,8 @@ class TestSplitYamlProvider(TestCase):
def test_unsorted(self): def test_unsorted(self):
source = SplitYamlProvider( source = SplitYamlProvider(
'test', join(dirname(__file__), 'config/split'))
'test', join(dirname(__file__), 'config/split'),
extension='.tst')
zone = Zone('unordered.', []) zone = Zone('unordered.', [])
@ -356,14 +365,15 @@ class TestSplitYamlProvider(TestCase):
source = SplitYamlProvider( source = SplitYamlProvider(
'test', join(dirname(__file__), 'config/split'), 'test', join(dirname(__file__), 'config/split'),
enforce_order=False)
extension='.tst', enforce_order=False)
# no exception # no exception
source.populate(zone) source.populate(zone)
self.assertEqual(2, len(zone.records)) self.assertEqual(2, len(zone.records))
def test_subzone_handling(self): def test_subzone_handling(self):
source = SplitYamlProvider( source = SplitYamlProvider(
'test', join(dirname(__file__), 'config/split'))
'test', join(dirname(__file__), 'config/split'),
extension='.tst')
# If we add `sub` as a sub-zone we'll reject `www.sub` # If we add `sub` as a sub-zone we'll reject `www.sub`
zone = Zone('unit.tests.', ['sub']) zone = Zone('unit.tests.', ['sub'])


+ 484
- 4
tests/test_octodns_record.py View File

@ -9,10 +9,11 @@ from six import text_type
from unittest import TestCase from unittest import TestCase
from octodns.record import ARecord, AaaaRecord, AliasRecord, CaaRecord, \ from octodns.record import ARecord, AaaaRecord, AliasRecord, CaaRecord, \
CaaValue, CnameRecord, DnameRecord, 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, LocRecord, \
LocValue, MxRecord, MxValue, NaptrRecord, NaptrValue, NsRecord, \
PtrRecord, Record, SshfpRecord, SshfpValue, SpfRecord, SrvRecord, \
SrvValue, TxtRecord, Update, ValidationError, _Dynamic, _DynamicPool, \
_DynamicRule
from octodns.zone import Zone from octodns.zone import Zone
from helpers import DynamicProvider, GeoProvider, SimpleProvider from helpers import DynamicProvider, GeoProvider, SimpleProvider
@ -379,6 +380,98 @@ class TestRecord(TestCase):
self.assertSingleValue(DnameRecord, 'target.foo.com.', self.assertSingleValue(DnameRecord, 'target.foo.com.',
'other.foo.com.') 'other.foo.com.')
def test_loc(self):
a_values = [{
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}]
a_data = {'ttl': 30, 'values': a_values}
a = LocRecord(self.zone, 'a', a_data)
self.assertEquals('a', a.name)
self.assertEquals('a.unit.tests.', a.fqdn)
self.assertEquals(30, a.ttl)
self.assertEquals(a_values[0]['lat_degrees'], a.values[0].lat_degrees)
self.assertEquals(a_values[0]['lat_minutes'], a.values[0].lat_minutes)
self.assertEquals(a_values[0]['lat_seconds'], a.values[0].lat_seconds)
self.assertEquals(a_values[0]['lat_direction'],
a.values[0].lat_direction)
self.assertEquals(a_values[0]['long_degrees'],
a.values[0].long_degrees)
self.assertEquals(a_values[0]['long_minutes'],
a.values[0].long_minutes)
self.assertEquals(a_values[0]['long_seconds'],
a.values[0].long_seconds)
self.assertEquals(a_values[0]['long_direction'],
a.values[0].long_direction)
self.assertEquals(a_values[0]['altitude'], a.values[0].altitude)
self.assertEquals(a_values[0]['size'], a.values[0].size)
self.assertEquals(a_values[0]['precision_horz'],
a.values[0].precision_horz)
self.assertEquals(a_values[0]['precision_vert'],
a.values[0].precision_vert)
b_value = {
'lat_degrees': 32,
'lat_minutes': 7,
'lat_seconds': 19,
'lat_direction': 'S',
'long_degrees': 116,
'long_minutes': 2,
'long_seconds': 25,
'long_direction': 'E',
'altitude': 10,
'size': 1,
'precision_horz': 10000,
'precision_vert': 10,
}
b_data = {'ttl': 30, 'value': b_value}
b = LocRecord(self.zone, 'b', b_data)
self.assertEquals(b_value['lat_degrees'], b.values[0].lat_degrees)
self.assertEquals(b_value['lat_minutes'], b.values[0].lat_minutes)
self.assertEquals(b_value['lat_seconds'], b.values[0].lat_seconds)
self.assertEquals(b_value['lat_direction'], b.values[0].lat_direction)
self.assertEquals(b_value['long_degrees'], b.values[0].long_degrees)
self.assertEquals(b_value['long_minutes'], b.values[0].long_minutes)
self.assertEquals(b_value['long_seconds'], b.values[0].long_seconds)
self.assertEquals(b_value['long_direction'],
b.values[0].long_direction)
self.assertEquals(b_value['altitude'], b.values[0].altitude)
self.assertEquals(b_value['size'], b.values[0].size)
self.assertEquals(b_value['precision_horz'],
b.values[0].precision_horz)
self.assertEquals(b_value['precision_vert'],
b.values[0].precision_vert)
self.assertEquals(b_data, b.data)
target = SimpleProvider()
# No changes with self
self.assertFalse(a.changes(a, target))
# Diff in lat_direction causes change
other = LocRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
other.values[0].lat_direction = 'N'
change = a.changes(other, target)
self.assertEqual(change.existing, a)
self.assertEqual(change.new, other)
# Diff in altitude causes change
other.values[0].altitude = a.values[0].altitude
other.values[0].altitude = -10
change = a.changes(other, target)
self.assertEqual(change.existing, a)
self.assertEqual(change.new, other)
# __repr__ doesn't blow up
a.__repr__()
def test_mx(self): def test_mx(self):
a_values = [{ a_values = [{
'preference': 10, 'preference': 10,
@ -1127,6 +1220,93 @@ class TestRecord(TestCase):
self.assertTrue(d >= d) self.assertTrue(d >= d)
self.assertTrue(d <= d) self.assertTrue(d <= d)
def test_loc_value(self):
a = LocValue({
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
})
b = LocValue({
'lat_degrees': 32,
'lat_minutes': 7,
'lat_seconds': 19,
'lat_direction': 'S',
'long_degrees': 116,
'long_minutes': 2,
'long_seconds': 25,
'long_direction': 'E',
'altitude': 10,
'size': 1,
'precision_horz': 10000,
'precision_vert': 10,
})
c = LocValue({
'lat_degrees': 53,
'lat_minutes': 14,
'lat_seconds': 10,
'lat_direction': 'N',
'long_degrees': 2,
'long_minutes': 18,
'long_seconds': 26,
'long_direction': 'W',
'altitude': 10,
'size': 1,
'precision_horz': 1000,
'precision_vert': 10,
})
self.assertEqual(a, a)
self.assertEqual(b, b)
self.assertEqual(c, c)
self.assertNotEqual(a, b)
self.assertNotEqual(a, c)
self.assertNotEqual(b, a)
self.assertNotEqual(b, c)
self.assertNotEqual(c, a)
self.assertNotEqual(c, b)
self.assertTrue(a < b)
self.assertTrue(a < c)
self.assertTrue(b > a)
self.assertTrue(b < c)
self.assertTrue(c > a)
self.assertTrue(c > b)
self.assertTrue(a <= b)
self.assertTrue(a <= c)
self.assertTrue(a <= a)
self.assertTrue(a >= a)
self.assertTrue(b >= a)
self.assertTrue(b <= c)
self.assertTrue(b >= b)
self.assertTrue(b <= b)
self.assertTrue(c >= a)
self.assertTrue(c >= b)
self.assertTrue(c >= c)
self.assertTrue(c <= c)
# Hash
values = set()
values.add(a)
self.assertTrue(a in values)
self.assertFalse(b in values)
values.add(b)
self.assertTrue(b in values)
def test_mx_value(self): def test_mx_value(self):
a = MxValue({'preference': 0, 'priority': 'a', 'exchange': 'v', a = MxValue({'preference': 0, 'priority': 'a', 'exchange': 'v',
'value': '1'}) 'value': '1'})
@ -1960,6 +2140,306 @@ class TestRecordValidation(TestCase):
self.assertEquals(['DNAME value "foo.bar.com" missing trailing .'], self.assertEquals(['DNAME value "foo.bar.com" missing trailing .'],
ctx.exception.reasons) ctx.exception.reasons)
def test_LOC(self):
# doesn't blow up
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
# missing int key
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['missing lat_degrees'], ctx.exception.reasons)
# missing float key
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['missing lat_seconds'], ctx.exception.reasons)
# missing text key
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['missing lat_direction'], ctx.exception.reasons)
# invalid direction
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'U',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid direction for lat_direction "U"'],
ctx.exception.reasons)
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'N',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid direction for long_direction "N"'],
ctx.exception.reasons)
# invalid degrees
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 360,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid value for lat_degrees "360"'],
ctx.exception.reasons)
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 'nope',
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid lat_degrees "nope"'],
ctx.exception.reasons)
# invalid minutes
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 60,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid value for lat_minutes "60"'],
ctx.exception.reasons)
# invalid seconds
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 60,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid value for lat_seconds "60"'],
ctx.exception.reasons)
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 'nope',
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid lat_seconds "nope"'],
ctx.exception.reasons)
# invalid altitude
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': -666666,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid value for altitude "-666666"'],
ctx.exception.reasons)
# invalid size
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
'type': 'LOC',
'ttl': 600,
'value': {
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 99999999.99,
'precision_horz': 10,
'precision_vert': 2,
}
})
self.assertEquals(['invalid value for size "99999999.99"'],
ctx.exception.reasons)
def test_MX(self): def test_MX(self):
# doesn't blow up # doesn't blow up
Record.new(self.zone, '', { Record.new(self.zone, '', {


+ 34
- 5
tests/test_octodns_source_axfr.py View File

@ -9,6 +9,8 @@ import dns.zone
from dns.exception import DNSException from dns.exception import DNSException
from mock import patch from mock import patch
from os.path import exists
from shutil import copyfile
from six import text_type from six import text_type
from unittest import TestCase from unittest import TestCase
@ -21,7 +23,7 @@ from octodns.record import ValidationError
class TestAxfrSource(TestCase): class TestAxfrSource(TestCase):
source = AxfrSource('test', 'localhost') source = AxfrSource('test', 'localhost')
forward_zonefile = dns.zone.from_file('./tests/zones/unit.tests.',
forward_zonefile = dns.zone.from_file('./tests/zones/unit.tests.tst',
'unit.tests', relativize=False) 'unit.tests', relativize=False)
@patch('dns.zone.from_xfr') @patch('dns.zone.from_xfr')
@ -34,7 +36,7 @@ class TestAxfrSource(TestCase):
] ]
self.source.populate(got) self.source.populate(got)
self.assertEquals(12, len(got.records))
self.assertEquals(15, len(got.records))
with self.assertRaises(AxfrSourceZoneTransferFailed) as ctx: with self.assertRaises(AxfrSourceZoneTransferFailed) as ctx:
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
@ -44,18 +46,45 @@ class TestAxfrSource(TestCase):
class TestZoneFileSource(TestCase): class TestZoneFileSource(TestCase):
source = ZoneFileSource('test', './tests/zones')
source = ZoneFileSource('test', './tests/zones', file_extension='.tst')
def test_zonefiles_with_extension(self):
source = ZoneFileSource('test', './tests/zones', '.extension')
# Load zonefiles with a specified file extension
valid = Zone('ext.unit.tests.', [])
source.populate(valid)
self.assertEquals(1, len(valid.records))
def test_zonefiles_without_extension(self):
# Windows doesn't let files end with a `.` so we add a .tst to them in
# the repo and then try and create the `.` version we need for the
# default case (no extension.)
copyfile('./tests/zones/unit.tests.tst', './tests/zones/unit.tests.')
# Unfortunately copyfile silently works and create the file without
# the `.` so we have to check to see if it did that
if exists('./tests/zones/unit.tests'):
# It did so we need to skip this test, that means windows won't
# have full code coverage, but skipping the test is going out of
# our way enough for a os-specific/oddball case.
self.skipTest('Unable to create unit.tests. (ending with .) so '
'skipping default filename testing.')
source = ZoneFileSource('test', './tests/zones')
# Load zonefiles without a specified file extension
valid = Zone('unit.tests.', [])
source.populate(valid)
self.assertEquals(15, len(valid.records))
def test_populate(self): def test_populate(self):
# Valid zone file in directory # Valid zone file in directory
valid = Zone('unit.tests.', []) valid = Zone('unit.tests.', [])
self.source.populate(valid) self.source.populate(valid)
self.assertEquals(12, len(valid.records))
self.assertEquals(15, len(valid.records))
# 2nd populate does not read file again # 2nd populate does not read file again
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
self.source.populate(again) self.source.populate(again)
self.assertEquals(12, len(again.records))
self.assertEquals(15, len(again.records))
# bust the cache # bust the cache
del self.source._zone_records[valid.name] del self.source._zone_records[valid.name]


+ 12
- 0
tests/zones/ext.unit.tests.extension View File

@ -0,0 +1,12 @@
$ORIGIN ext.unit.tests.
@ 3600 IN SOA ns1.ext.unit.tests. root.ext.unit.tests. (
2018071501 ; Serial
3600 ; Refresh (1 hour)
600 ; Retry (10 minutes)
604800 ; Expire (1 week)
3600 ; NXDOMAIN ttl (1 hour)
)
; NS Records
@ 3600 IN NS ns1.ext.unit.tests.
@ 3600 IN NS ns2.ext.unit.tests.

tests/zones/invalid.records. → tests/zones/invalid.records.tst View File


tests/zones/invalid.zone. → tests/zones/invalid.zone.tst View File


tests/zones/unit.tests. → tests/zones/unit.tests.tst View File


Loading…
Cancel
Save