mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
Merge branch 'master' into azuredns_txt
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
|
||||
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,7 +6,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [2.7, 3.7]
|
||||
# Tested versions based on dates in https://devguide.python.org/devcycle/#end-of-life-branches,
|
||||
# with the addition of 2.7 b/c it's still if pretty wide active use.
|
||||
python-version: [2.7, 3.6, 3.7, 3.8, 3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Setup python
|
||||
|
||||
+30
-5
@@ -1,6 +1,31 @@
|
||||
## v0.9.11 - 2020-??-?? - ???????????????
|
||||
## v0.9.11 - 2020-11-05 - We still don't know edition
|
||||
|
||||
* Added support for TCP health checking to dynamic records
|
||||
#### Noteworthy changtes
|
||||
|
||||
* ALIAS records only allowed at the root of zones - see `leient` in record docs
|
||||
for work-arounds if you really need them.
|
||||
|
||||
#### New Providers
|
||||
|
||||
* Gandi LiveDNS
|
||||
* UltraDNS
|
||||
* easyDNS
|
||||
|
||||
#### Stuff
|
||||
|
||||
* Add support for zones aliases
|
||||
* octodns-compare: Prefix filtering and status code on on mismatch
|
||||
* Implement octodns-sync --source
|
||||
* Adding environment variable record injection
|
||||
* Add support for wildcard SRV records, as shown in RFC 2782
|
||||
* Add healthcheck option 'request_interval' for Route53 provider
|
||||
* NS1 georegion, country, and catchall need to be separate groups
|
||||
* Add the ability to mark a zone as lenient
|
||||
* Add support for geo-targeting of CA provinces
|
||||
* Update geo_data to pick up a couple renames
|
||||
* Cloudflare: Add PTR Support, update rate-limit handling and pagination
|
||||
* Support PowerDNS 4.3.x
|
||||
* Added support for TCP health checking of dynamic records
|
||||
|
||||
## v0.9.10 - 2020-04-20 - Dynamic NS1 and lots of misc
|
||||
|
||||
@@ -30,7 +55,7 @@
|
||||
* Explicit ordering of changes by (name, type) to address inconsistent
|
||||
ordering for a number of providers that just convert changes into API
|
||||
calls as they come. Python 2 sets ordered consistently, Python 3 they do
|
||||
not. https://github.com/github/octodns/pull/384/commits/7958233fccf9ea22d95e2fd06c48d7d0a4529e26
|
||||
not. https://github.com/octodns/octodns/pull/384/commits/7958233fccf9ea22d95e2fd06c48d7d0a4529e26
|
||||
* Route53 `_mod_keyer` ordering wasn't 100% complete and thus unreliable and
|
||||
random in Python 3. This has been addressed and may result in value
|
||||
reordering on next plan, no actual changes in behavior should occur.
|
||||
@@ -127,10 +152,10 @@ recreating all health checks. This process has been tested pretty thoroughly to
|
||||
try and ensure a seemless upgrade without any traffic shifting around. It's
|
||||
probably best to take extra care when updating and to try and make sure that
|
||||
all health checks are passing before the first sync with `--doit`. See
|
||||
[#67](https://github.com/github/octodns/pull/67) for more information.
|
||||
[#67](https://github.com/octodns/octodns/pull/67) for more information.
|
||||
|
||||
* Major update to geo healthchecks to allow configuring host (header), path,
|
||||
protocol, and port [#67](https://github.com/github/octodns/pull/67)
|
||||
protocol, and port [#67](https://github.com/octodns/octodns/pull/67)
|
||||
* SSHFP algorithm type 4
|
||||
* NS1 and DNSimple support skipping unsupported record types
|
||||
* Revert back to old style setup.py & requirements.txt, setup.cfg was
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Hi there! We're thrilled that you'd like to contribute to OctoDNS. Your help is
|
||||
|
||||
Please note that this project adheres to the [Contributor Covenant Code of Conduct](/CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
|
||||
|
||||
If you have questions, or you'd like to check with us before embarking on a major development effort, please [open an issue](https://github.com/github/octodns/issues/new).
|
||||
If you have questions, or you'd like to check with us before embarking on a major development effort, please [open an issue](https://github.com/octodns/octodns/issues/new).
|
||||
|
||||
## How to contribute
|
||||
|
||||
|
||||
+8
-4
@@ -1,7 +1,11 @@
|
||||
include README.md
|
||||
include CHANGELOG.md
|
||||
include CODE_OF_CONDUCT.md
|
||||
include CONTRIBUTING.md
|
||||
include LICENSE
|
||||
include docs/*
|
||||
include octodns/*
|
||||
include README.md
|
||||
include requirements-dev.txt
|
||||
include requirements.txt
|
||||
include script/*
|
||||
include tests/*
|
||||
recursive-include docs *.png *.md
|
||||
recursive-include tests *.json *.py *.txt *.yaml
|
||||
recursive-include tests/zones *
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<img src="https://raw.githubusercontent.com/github/octodns/master/docs/logos/octodns-logo.png?" height=251 width=404>
|
||||
<img src="https://raw.githubusercontent.com/octodns/octodns/master/docs/logos/octodns-logo.png?" height=251 width=404>
|
||||
|
||||
## DNS as code - Tools for managing DNS across multiple providers
|
||||
|
||||
@@ -79,6 +79,9 @@ zones:
|
||||
targets:
|
||||
- dyn
|
||||
- route53
|
||||
|
||||
example.net:
|
||||
alias: example.com.
|
||||
```
|
||||
|
||||
`class` is a special key that tells OctoDNS what python class should be loaded. Any other keys will be passed as configuration values to that provider. In general any sensitive or frequently rotated values should come from environmental variables. When OctoDNS sees a value that starts with `env/` it will look for that value in the process's environment and pass the result along.
|
||||
@@ -87,6 +90,8 @@ Further information can be found in the `docstring` of each source and provider
|
||||
|
||||
The `max_workers` key in the `manager` section of the config enables threading to parallelize the planning portion of the sync.
|
||||
|
||||
In this example, `example.net` is an alias of zone `example.com`, which means they share the same sources and targets. They will therefore have identical records.
|
||||
|
||||
Now that we have something to tell OctoDNS about our providers & zones we need to tell it about or records. We'll keep it simple for now and just create a single `A` record at the top-level of the domain.
|
||||
|
||||
`config/example.com.yaml`
|
||||
@@ -97,8 +102,8 @@ Now that we have something to tell OctoDNS about our providers & zones we need t
|
||||
ttl: 60
|
||||
type: A
|
||||
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).
|
||||
@@ -189,9 +194,10 @@ The above command pulled the existing data out of Route53 and placed the results
|
||||
| [EasyDNSProvider](/octodns/provider/easydns.py) | | A, AAAA, CAA, CNAME, MX, NAPTR, NS, SRV, TXT | No | |
|
||||
| [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | A, AAAA, ALIAS, CNAME | No | |
|
||||
| [EnvVarSource](/octodns/source/envvar.py) | | TXT | No | read-only environment variable injection |
|
||||
| [GandiProvider](/octodns/provider/gandi.py) | | A, AAAA, ALIAS, CAA, CNAME, DNAME, MX, NS, PTR, SPF, SRV, SSHFP, TXT | No | |
|
||||
| [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | |
|
||||
| [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | |
|
||||
| [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | No CNAME support, missing `NA` geo target |
|
||||
| [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | Missing `NA` geo target |
|
||||
| [OVH](/octodns/provider/ovh.py) | ovh | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT, DKIM | No | |
|
||||
| [PowerDnsProvider](/octodns/provider/powerdns.py) | | All | No | |
|
||||
| [Rackspace](/octodns/provider/rackspace.py) | | A, AAAA, ALIAS, CNAME, MX, NS, PTR, SPF, TXT | No | |
|
||||
@@ -199,8 +205,8 @@ The above command pulled the existing data out of Route53 and placed the results
|
||||
| [Selectel](/octodns/provider/selectel.py) | | A, AAAA, CNAME, MX, NS, SPF, SRV, TXT | No | |
|
||||
| [Transip](/octodns/provider/transip.py) | transip | A, AAAA, CNAME, MX, SRV, SPF, TXT, SSHFP, CAA | No | |
|
||||
| [UltraDns](/octodns/provider/ultra.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | |
|
||||
| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
|
||||
| [ZoneFileSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
|
||||
| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
|
||||
| [ZoneFileSource](/octodns/source/axfr.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
|
||||
| [TinyDnsFileSource](/octodns/source/tinydns.py) | | A, CNAME, MX, NS, PTR | No | read-only |
|
||||
| [YamlProvider](/octodns/provider/yaml.py) | | All | Yes | config |
|
||||
|
||||
@@ -278,13 +284,13 @@ Please see our [contributing document](/CONTRIBUTING.md) if you would like to pa
|
||||
|
||||
## Getting help
|
||||
|
||||
If you have a problem or suggestion, please [open an issue](https://github.com/github/octodns/issues/new) in this repository, and we will do our best to help. Please note that this project adheres to the [Contributor Covenant Code of Conduct](/CODE_OF_CONDUCT.md).
|
||||
If you have a problem or suggestion, please [open an issue](https://github.com/octodns/octodns/issues/new) in this repository, and we will do our best to help. Please note that this project adheres to the [Contributor Covenant Code of Conduct](/CODE_OF_CONDUCT.md).
|
||||
|
||||
## License
|
||||
|
||||
OctoDNS is licensed under the [MIT license](LICENSE).
|
||||
|
||||
The MIT license grant is not for GitHub's trademarks, which include the logo designs. GitHub reserves all trademark and copyright rights in and to all GitHub trademarks. GitHub's logos include, for instance, the stylized designs that include "logo" in the file title in the following folder: https://github.com/github/octodns/tree/master/docs/logos/
|
||||
The MIT license grant is not for GitHub's trademarks, which include the logo designs. GitHub reserves all trademark and copyright rights in and to all GitHub trademarks. GitHub's logos include, for instance, the stylized designs that include "logo" in the file title in the following folder: https://github.com/octodns/octodns/tree/master/docs/logos/
|
||||
|
||||
GitHub® and its stylized versions and the Invertocat mark are GitHub's Trademarks or registered Trademarks. When using GitHub's logos, be sure to follow the GitHub logo guidelines.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
## Geo Record Support
|
||||
|
||||
Note: Geo DNS records are still supported for the time being, but it is still strongy encouraged that you look at [Dynamic Records](/docs/dynamic_records.md) instead as they are a superset of functionality.
|
||||
Note: Geo DNS records are still supported for the time being, but it is still strongly encouraged that you look at [Dynamic Records](/docs/dynamic_records.md) instead as they are a superset of functionality.
|
||||
|
||||
GeoDNS is currently supported for `A` and `AAAA` records on the Dyn (via Traffic Directors) and Route53 providers. Records with geo information pushed to providers without support for them will be managed as non-geo records using the base values.
|
||||
|
||||
|
||||
+55
-1
@@ -6,14 +6,17 @@ OctoDNS supports the following record types:
|
||||
|
||||
* `A`
|
||||
* `AAAA`
|
||||
* `ALIAS`
|
||||
* `CAA`
|
||||
* `CNAME`
|
||||
* `DNAME`
|
||||
* `MX`
|
||||
* `NAPTR`
|
||||
* `NS`
|
||||
* `PTR`
|
||||
* `SSHFP`
|
||||
* `SPF`
|
||||
* `SRV`
|
||||
* `SSHFP`
|
||||
* `TXT`
|
||||
|
||||
Underlying provider support for each of these varies and some providers have extra requirements or limitations. In cases where a record type is not supported by a provider OctoDNS will ignore it there and continue to manage the record elsewhere. For example `SSHFP` is supported by Dyn, but not Route53. If your source data includes an SSHFP record OctoDNS will keep it in sync on Dyn, but not consider it when evaluating the state of Route53. The best way to find out what types are supported by a provider is to look for its `supports` method. If that method exists the logic will drive which records are supported and which are ignored. If the provider does not implement the method it will fall back to `BaseProvider.supports` which indicates full support.
|
||||
@@ -81,3 +84,54 @@ In the above example each name had a single record, but there are cases where a
|
||||
Each record type has a corresponding set of required data. The easiest way to determine what's required is probably to look at the record object in [`octodns/record/__init__.py`](/octodns/record/__init__.py). You may also utilize `octodns-validate` which will throw errors about what's missing when run.
|
||||
|
||||
`type` is required for all records. `ttl` is optional. When TTL is not specified the `YamlProvider`'s default will be used. In any situation where an array of `values` can be used you can opt to go with `value` as a single item if there's only one.
|
||||
|
||||
### Lenience
|
||||
|
||||
octoDNS is fairly strict in terms of standards compliance and is opinionated in terms of best practices. Examples of former include SRV record naming requirements and the latter that ALIAS records are constrained to the root of zones. The strictness and support of providers varies so you may encounter existing records that fail validation when you try to dump them or you may even have use cases for which you need to create or preserve records that don't validate. octoDNS's solution to this is the `lenient` flag.
|
||||
|
||||
It's best to think of the `lenient` flag as "I know what I'm doing and accept any problems I run across." The main reason being is that some providers may allow the non-compliant setup and others may not. The behavior of the non-compliant records may even vary from one provider to another. Caveat emptor.
|
||||
|
||||
#### octodns-dump
|
||||
|
||||
If you're trying to import a zone into octoDNS config file using `octodns-dump` which fails due to validation errors you can supply the `--lenient` argument to tell octoDNS that you acknowledge that things aren't lining up with its expectations, but you'd like it to go ahead anyway. This will do its best to populate the zone and dump the results out into an octoDNS zone file and include the non-compliant bits. If you go to use that config file octoDNS will again complain about the validation problems. You can correct them in cases where that makes sense, but if you need to preserve the non-compliant records read on for options.
|
||||
|
||||
#### Record level lenience
|
||||
|
||||
When there are non-compliant records configured in Yaml you can add the following to tell octoDNS to do it's best to proceed with them anyway. If you use `--lenient` above to dump a zone and you'd like to sync it as-is you can mark the problematic records this way.
|
||||
|
||||
```yaml
|
||||
'not-root':
|
||||
octodns:
|
||||
lenient: true
|
||||
type: ALIAS
|
||||
values: something.else.com.
|
||||
```
|
||||
|
||||
#### Zone level lenience
|
||||
|
||||
If you'd like to enable lenience for a whole zone you can do so with the following, thought it's strongly encouraged to mark things at record level when possible. The most common case where things may need to be done at the zone level is when using something other than `YamlProvider` as a source, e.g. syncing from `Route53Provider` to `Ns1Provider` when there are non-compliant records in the zone in Route53.
|
||||
|
||||
```yaml
|
||||
non-compliant-zone.com.:
|
||||
octodns:
|
||||
lenient: true
|
||||
sources:
|
||||
- route53
|
||||
targets:
|
||||
- ns1
|
||||
```
|
||||
|
||||
#### Restrict Record manipulations
|
||||
|
||||
OctoDNS currently provides the ability to limit the number of updates/deletes on
|
||||
DNS records by configuring a percentage of allowed operations as a threshold.
|
||||
If left unconfigured, suitable defaults take over instead. In the below example,
|
||||
the Dyn provider is configured with limits of 40% on both update and
|
||||
delete operations over all the records present.
|
||||
|
||||
````yaml
|
||||
dyn:
|
||||
class: octodns.provider.dyn.DynProvider
|
||||
update_pcent_threshold: 0.4
|
||||
delete_pcent_threshold: 0.4
|
||||
````
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
from __future__ import absolute_import, division, print_function, \
|
||||
unicode_literals
|
||||
|
||||
__VERSION__ = '0.9.10'
|
||||
__VERSION__ = '0.9.11'
|
||||
|
||||
+14
-1
@@ -7,6 +7,7 @@ from __future__ import absolute_import, division, print_function, \
|
||||
unicode_literals
|
||||
|
||||
from pprint import pprint
|
||||
import sys
|
||||
|
||||
from octodns.cmds.args import ArgumentParser
|
||||
from octodns.manager import Manager
|
||||
@@ -23,13 +24,25 @@ def main():
|
||||
help='Second source(s) to pull data from')
|
||||
parser.add_argument('--zone', default=None, required=True,
|
||||
help='Zone to compare')
|
||||
|
||||
parser.add_argument('--ignore-prefix', default=None, required=False,
|
||||
help='Record prefix to ignore from list of changes')
|
||||
args = parser.parse_args()
|
||||
|
||||
manager = Manager(args.config_file)
|
||||
changes = manager.compare(args.a, args.b, args.zone)
|
||||
|
||||
# Filter changes list based on ignore-prefix argument if present
|
||||
if args.ignore_prefix:
|
||||
pattern = args.ignore_prefix
|
||||
changes = [c for c in changes
|
||||
if not c.record.fqdn.startswith(pattern)]
|
||||
|
||||
pprint(changes)
|
||||
|
||||
# Exit with non-zero exit code if changes exist
|
||||
if len(changes):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -17,7 +17,6 @@ from six import text_type
|
||||
|
||||
from octodns.cmds.args import ArgumentParser
|
||||
from octodns.manager import Manager
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class AsyncResolver(Resolver):
|
||||
@@ -56,7 +55,7 @@ def main():
|
||||
except KeyError as e:
|
||||
raise Exception('Unknown source: {}'.format(e.args[0]))
|
||||
|
||||
zone = Zone(args.zone, manager.configured_sub_zones(args.zone))
|
||||
zone = manager.get_zone(args.zone)
|
||||
for source in sources:
|
||||
source.populate(zone)
|
||||
|
||||
|
||||
@@ -25,18 +25,19 @@ def main():
|
||||
parser.add_argument('zone', nargs='*', default=[],
|
||||
help='Limit sync to the specified zone(s)')
|
||||
|
||||
# --sources isn't an option here b/c filtering sources out would be super
|
||||
# dangerous since you could easily end up with an empty zone and delete
|
||||
# everything, or even just part of things when there are multiple sources
|
||||
|
||||
parser.add_argument('--source', default=[], action='append',
|
||||
help='Limit sync to zones with the specified '
|
||||
'source(s) (all sources will be synchronized for the '
|
||||
'selected zones)')
|
||||
parser.add_argument('--target', default=[], action='append',
|
||||
help='Limit sync to the specified target(s)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
manager = Manager(args.config_file)
|
||||
manager.sync(eligible_zones=args.zone, eligible_targets=args.target,
|
||||
dry_run=not args.doit, force=args.force)
|
||||
manager.sync(eligible_zones=args.zone, eligible_sources=args.source,
|
||||
eligible_targets=args.target, dry_run=not args.doit,
|
||||
force=args.force)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+120
-22
@@ -222,21 +222,31 @@ class Manager(object):
|
||||
self.log.debug('configured_sub_zones: subs=%s', sub_zone_names)
|
||||
return set(sub_zone_names)
|
||||
|
||||
def _populate_and_plan(self, zone_name, sources, targets, lenient=False):
|
||||
def _populate_and_plan(self, zone_name, sources, targets, desired=None,
|
||||
lenient=False):
|
||||
|
||||
self.log.debug('sync: populating, zone=%s, lenient=%s',
|
||||
zone_name, lenient)
|
||||
zone = Zone(zone_name,
|
||||
sub_zones=self.configured_sub_zones(zone_name))
|
||||
for source in sources:
|
||||
try:
|
||||
source.populate(zone, lenient=lenient)
|
||||
except TypeError as e:
|
||||
if "keyword argument 'lenient'" not in text_type(e):
|
||||
raise
|
||||
self.log.warn(': provider %s does not accept lenient param',
|
||||
source.__class__.__name__)
|
||||
source.populate(zone)
|
||||
|
||||
if desired:
|
||||
# This is an alias zone, rather than populate it we'll copy the
|
||||
# records over from `desired`.
|
||||
for _, records in desired._records.items():
|
||||
for record in records:
|
||||
zone.add_record(record.copy(zone=zone), lenient=lenient)
|
||||
|
||||
else:
|
||||
for source in sources:
|
||||
try:
|
||||
source.populate(zone, lenient=lenient)
|
||||
except TypeError as e:
|
||||
if "keyword argument 'lenient'" not in text_type(e):
|
||||
raise
|
||||
self.log.warn(': provider %s does not accept lenient '
|
||||
'param', source.__class__.__name__)
|
||||
source.populate(zone)
|
||||
|
||||
self.log.debug('sync: planning, zone=%s', zone_name)
|
||||
plans = []
|
||||
@@ -253,10 +263,11 @@ class Manager(object):
|
||||
if plan:
|
||||
plans.append((target, plan))
|
||||
|
||||
return plans
|
||||
# Return the zone as it's the desired state
|
||||
return plans, zone
|
||||
|
||||
def sync(self, eligible_zones=[], eligible_targets=[], dry_run=True,
|
||||
force=False):
|
||||
def sync(self, eligible_zones=[], eligible_sources=[], eligible_targets=[],
|
||||
dry_run=True, force=False):
|
||||
self.log.info('sync: eligible_zones=%s, eligible_targets=%s, '
|
||||
'dry_run=%s, force=%s', eligible_zones, eligible_targets,
|
||||
dry_run, force)
|
||||
@@ -265,9 +276,32 @@ class Manager(object):
|
||||
if eligible_zones:
|
||||
zones = [z for z in zones if z[0] in eligible_zones]
|
||||
|
||||
aliased_zones = {}
|
||||
futures = []
|
||||
for zone_name, config in zones:
|
||||
self.log.info('sync: zone=%s', zone_name)
|
||||
if 'alias' in config:
|
||||
source_zone = config['alias']
|
||||
|
||||
# Check that the source zone is defined.
|
||||
if source_zone not in self.config['zones']:
|
||||
self.log.error('Invalid alias zone {}, target {} does '
|
||||
'not exist'.format(zone_name, source_zone))
|
||||
raise ManagerException('Invalid alias zone {}: '
|
||||
'source zone {} does not exist'
|
||||
.format(zone_name, source_zone))
|
||||
|
||||
# Check that the source zone is not an alias zone itself.
|
||||
if 'alias' in self.config['zones'][source_zone]:
|
||||
self.log.error('Invalid alias zone {}, target {} is an '
|
||||
'alias zone'.format(zone_name, source_zone))
|
||||
raise ManagerException('Invalid alias zone {}: source '
|
||||
'zone {} is an alias zone'
|
||||
.format(zone_name, source_zone))
|
||||
|
||||
aliased_zones[zone_name] = source_zone
|
||||
continue
|
||||
|
||||
lenient = config.get('lenient', False)
|
||||
try:
|
||||
sources = config['sources']
|
||||
@@ -280,6 +314,12 @@ class Manager(object):
|
||||
except KeyError:
|
||||
raise ManagerException('Zone {} is missing targets'
|
||||
.format(zone_name))
|
||||
|
||||
if (eligible_sources and not
|
||||
[s for s in sources if s in eligible_sources]):
|
||||
self.log.info('sync: no eligible sources, skipping')
|
||||
continue
|
||||
|
||||
if eligible_targets:
|
||||
targets = [t for t in targets if t in eligible_targets]
|
||||
|
||||
@@ -321,9 +361,38 @@ class Manager(object):
|
||||
zone_name, sources,
|
||||
targets, lenient=lenient))
|
||||
|
||||
# Wait on all results and unpack/flatten them in to a list of target &
|
||||
# plan pairs.
|
||||
plans = [p for f in futures for p in f.result()]
|
||||
# Wait on all results and unpack/flatten the plans and store the
|
||||
# desired states in case we need them below
|
||||
plans = []
|
||||
desired = {}
|
||||
for future in futures:
|
||||
ps, d = future.result()
|
||||
desired[d.name] = d
|
||||
for plan in ps:
|
||||
plans.append(plan)
|
||||
|
||||
# Populate aliases zones.
|
||||
futures = []
|
||||
for zone_name, zone_source in aliased_zones.items():
|
||||
source_config = self.config['zones'][zone_source]
|
||||
try:
|
||||
desired_config = desired[zone_source]
|
||||
except KeyError:
|
||||
raise ManagerException('Zone {} cannot be sync without zone '
|
||||
'{} sinced it is aliased'
|
||||
.format(zone_name, zone_source))
|
||||
futures.append(self._executor.submit(
|
||||
self._populate_and_plan,
|
||||
zone_name,
|
||||
[],
|
||||
[self.providers[t] for t in source_config['targets']],
|
||||
desired=desired_config,
|
||||
lenient=lenient
|
||||
))
|
||||
|
||||
# Wait on results and unpack/flatten the plans, ignore the desired here
|
||||
# as these are aliased zones
|
||||
plans += [p for f in futures for p in f.result()[0]]
|
||||
|
||||
# Best effort sort plans children first so that we create/update
|
||||
# children zones before parents which should allow us to more safely
|
||||
@@ -371,12 +440,11 @@ class Manager(object):
|
||||
except KeyError as e:
|
||||
raise ManagerException('Unknown source: {}'.format(e.args[0]))
|
||||
|
||||
sub_zones = self.configured_sub_zones(zone)
|
||||
za = Zone(zone, sub_zones)
|
||||
za = self.get_zone(zone)
|
||||
for source in a:
|
||||
source.populate(za)
|
||||
|
||||
zb = Zone(zone, sub_zones)
|
||||
zb = self.get_zone(zone)
|
||||
for source in b:
|
||||
source.populate(zb)
|
||||
|
||||
@@ -415,6 +483,25 @@ class Manager(object):
|
||||
for zone_name, config in self.config['zones'].items():
|
||||
zone = Zone(zone_name, self.configured_sub_zones(zone_name))
|
||||
|
||||
source_zone = config.get('alias')
|
||||
if source_zone:
|
||||
if source_zone not in self.config['zones']:
|
||||
self.log.exception('Invalid alias zone')
|
||||
raise ManagerException('Invalid alias zone {}: '
|
||||
'source zone {} does not exist'
|
||||
.format(zone_name, source_zone))
|
||||
|
||||
if 'alias' in self.config['zones'][source_zone]:
|
||||
self.log.exception('Invalid alias zone')
|
||||
raise ManagerException('Invalid alias zone {}: '
|
||||
'source zone {} is an alias zone'
|
||||
.format(zone_name, source_zone))
|
||||
|
||||
# this is just here to satisfy coverage, see
|
||||
# https://github.com/nedbat/coveragepy/issues/198
|
||||
source_zone = source_zone
|
||||
continue
|
||||
|
||||
try:
|
||||
sources = config['sources']
|
||||
except KeyError:
|
||||
@@ -422,9 +509,9 @@ class Manager(object):
|
||||
.format(zone_name))
|
||||
|
||||
try:
|
||||
# rather than using a list comprehension, we break this loop
|
||||
# out so that the `except` block below can reference the
|
||||
# `source`
|
||||
# rather than using a list comprehension, we break this
|
||||
# loop out so that the `except` block below can reference
|
||||
# the `source`
|
||||
collected = []
|
||||
for source in sources:
|
||||
collected.append(self.providers[source])
|
||||
@@ -436,3 +523,14 @@ class Manager(object):
|
||||
for source in sources:
|
||||
if isinstance(source, YamlProvider):
|
||||
source.populate(zone)
|
||||
|
||||
def get_zone(self, zone_name):
|
||||
if not zone_name[-1] == '.':
|
||||
raise ManagerException('Invalid zone name {}, missing ending dot'
|
||||
.format(zone_name))
|
||||
|
||||
for name, config in self.config['zones'].items():
|
||||
if name == zone_name:
|
||||
return Zone(name, self.configured_sub_zones(name))
|
||||
|
||||
raise ManagerException('Unknown zone name {}'.format(zone_name))
|
||||
|
||||
@@ -461,14 +461,8 @@ class AzureProvider(BaseProvider):
|
||||
:type azrecord: azure.mgmt.dns.models.RecordSet
|
||||
|
||||
:type return: dict
|
||||
|
||||
CNAME and PTR both use the catch block to catch possible empty
|
||||
records. Refer to population comment.
|
||||
'''
|
||||
try:
|
||||
return {'value': _check_endswith_dot(azrecord.cname_record.cname)}
|
||||
except:
|
||||
return {'value': '.'}
|
||||
return {'value': _check_endswith_dot(azrecord.cname_record.cname)}
|
||||
|
||||
def _data_for_MX(self, azrecord):
|
||||
return {'values': [{'preference': ar.preference,
|
||||
@@ -480,11 +474,8 @@ class AzureProvider(BaseProvider):
|
||||
return {'values': [_check_endswith_dot(val) for val in vals]}
|
||||
|
||||
def _data_for_PTR(self, azrecord):
|
||||
try:
|
||||
ptrdname = azrecord.ptr_records[0].ptrdname
|
||||
return {'value': _check_endswith_dot(ptrdname)}
|
||||
except:
|
||||
return {'value': '.'}
|
||||
ptrdname = azrecord.ptr_records[0].ptrdname
|
||||
return {'value': _check_endswith_dot(ptrdname)}
|
||||
|
||||
def _data_for_SRV(self, azrecord):
|
||||
return {'values': [{'priority': ar.priority, 'weight': ar.weight,
|
||||
@@ -542,6 +533,19 @@ class AzureProvider(BaseProvider):
|
||||
azure_zone_name = desired.name[:len(desired.name) - 1]
|
||||
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__
|
||||
getattr(self, '_apply_{}'.format(class_name))(change)
|
||||
if class_name == 'Delete':
|
||||
self._apply_Delete(change)
|
||||
|
||||
for change in changes:
|
||||
class_name = change.__class__.__name__
|
||||
if class_name != 'Delete':
|
||||
getattr(self, '_apply_{}'.format(class_name))(change)
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from __future__ import absolute_import, division, print_function, \
|
||||
unicode_literals
|
||||
|
||||
from collections import defaultdict
|
||||
from requests import Session
|
||||
import logging
|
||||
|
||||
from ..record import Record
|
||||
from .base import BaseProvider
|
||||
|
||||
|
||||
class GandiClientException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GandiClientBadRequest(GandiClientException):
|
||||
|
||||
def __init__(self, r):
|
||||
super(GandiClientBadRequest, self).__init__(r.text)
|
||||
|
||||
|
||||
class GandiClientUnauthorized(GandiClientException):
|
||||
|
||||
def __init__(self, r):
|
||||
super(GandiClientUnauthorized, self).__init__(r.text)
|
||||
|
||||
|
||||
class GandiClientForbidden(GandiClientException):
|
||||
|
||||
def __init__(self, r):
|
||||
super(GandiClientForbidden, self).__init__(r.text)
|
||||
|
||||
|
||||
class GandiClientNotFound(GandiClientException):
|
||||
|
||||
def __init__(self, r):
|
||||
super(GandiClientNotFound, self).__init__(r.text)
|
||||
|
||||
|
||||
class GandiClientUnknownDomainName(GandiClientException):
|
||||
|
||||
def __init__(self, msg):
|
||||
super(GandiClientUnknownDomainName, self).__init__(msg)
|
||||
|
||||
|
||||
class GandiClient(object):
|
||||
|
||||
def __init__(self, token):
|
||||
session = Session()
|
||||
session.headers.update({'Authorization': 'Apikey {}'.format(token)})
|
||||
self._session = session
|
||||
self.endpoint = 'https://api.gandi.net/v5'
|
||||
|
||||
def _request(self, method, path, params={}, data=None):
|
||||
url = '{}{}'.format(self.endpoint, path)
|
||||
r = self._session.request(method, url, params=params, json=data)
|
||||
if r.status_code == 400:
|
||||
raise GandiClientBadRequest(r)
|
||||
if r.status_code == 401:
|
||||
raise GandiClientUnauthorized(r)
|
||||
elif r.status_code == 403:
|
||||
raise GandiClientForbidden(r)
|
||||
elif r.status_code == 404:
|
||||
raise GandiClientNotFound(r)
|
||||
r.raise_for_status()
|
||||
return r
|
||||
|
||||
def zone(self, zone_name):
|
||||
return self._request('GET', '/livedns/domains/{}'
|
||||
.format(zone_name)).json()
|
||||
|
||||
def zone_create(self, zone_name):
|
||||
return self._request('POST', '/livedns/domains', data={
|
||||
'fqdn': zone_name,
|
||||
'zone': {}
|
||||
}).json()
|
||||
|
||||
def zone_records(self, zone_name):
|
||||
records = self._request('GET', '/livedns/domains/{}/records'
|
||||
.format(zone_name)).json()
|
||||
|
||||
for record in records:
|
||||
if record['rrset_name'] == '@':
|
||||
record['rrset_name'] = ''
|
||||
|
||||
# Change relative targets to absolute ones.
|
||||
if record['rrset_type'] in ['ALIAS', 'CNAME', 'DNAME', 'MX',
|
||||
'NS', 'SRV']:
|
||||
for i, value in enumerate(record['rrset_values']):
|
||||
if not value.endswith('.'):
|
||||
record['rrset_values'][i] = '{}.{}.'.format(
|
||||
value, zone_name)
|
||||
|
||||
return records
|
||||
|
||||
def record_create(self, zone_name, data):
|
||||
self._request('POST', '/livedns/domains/{}/records'.format(zone_name),
|
||||
data=data)
|
||||
|
||||
def record_delete(self, zone_name, record_name, record_type):
|
||||
self._request('DELETE', '/livedns/domains/{}/records/{}/{}'
|
||||
.format(zone_name, record_name, record_type))
|
||||
|
||||
|
||||
class GandiProvider(BaseProvider):
|
||||
'''
|
||||
Gandi provider using API v5.
|
||||
|
||||
gandi:
|
||||
class: octodns.provider.gandi.GandiProvider
|
||||
# Your API key (required)
|
||||
token: XXXXXXXXXXXX
|
||||
'''
|
||||
|
||||
SUPPORTS_GEO = False
|
||||
SUPPORTS_DYNAMIC = False
|
||||
SUPPORTS = set((['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME',
|
||||
'MX', 'NS', 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT']))
|
||||
|
||||
def __init__(self, id, token, *args, **kwargs):
|
||||
self.log = logging.getLogger('GandiProvider[{}]'.format(id))
|
||||
self.log.debug('__init__: id=%s, token=***', id)
|
||||
super(GandiProvider, self).__init__(id, *args, **kwargs)
|
||||
self._client = GandiClient(token)
|
||||
|
||||
self._zone_records = {}
|
||||
|
||||
def _data_for_multiple(self, _type, records):
|
||||
return {
|
||||
'ttl': records[0]['rrset_ttl'],
|
||||
'type': _type,
|
||||
'values': [v.replace(';', '\\;') for v in
|
||||
records[0]['rrset_values']] if _type == 'TXT' else
|
||||
records[0]['rrset_values']
|
||||
}
|
||||
|
||||
_data_for_A = _data_for_multiple
|
||||
_data_for_AAAA = _data_for_multiple
|
||||
_data_for_TXT = _data_for_multiple
|
||||
_data_for_SPF = _data_for_multiple
|
||||
_data_for_NS = _data_for_multiple
|
||||
|
||||
def _data_for_CAA(self, _type, records):
|
||||
values = []
|
||||
for record in records[0]['rrset_values']:
|
||||
flags, tag, value = record.split(' ')
|
||||
values.append({
|
||||
'flags': flags,
|
||||
'tag': tag,
|
||||
# Remove quotes around value.
|
||||
'value': value[1:-1],
|
||||
})
|
||||
|
||||
return {
|
||||
'ttl': records[0]['rrset_ttl'],
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
def _data_for_single(self, _type, records):
|
||||
return {
|
||||
'ttl': records[0]['rrset_ttl'],
|
||||
'type': _type,
|
||||
'value': records[0]['rrset_values'][0]
|
||||
}
|
||||
|
||||
_data_for_ALIAS = _data_for_single
|
||||
_data_for_CNAME = _data_for_single
|
||||
_data_for_DNAME = _data_for_single
|
||||
_data_for_PTR = _data_for_single
|
||||
|
||||
def _data_for_MX(self, _type, records):
|
||||
values = []
|
||||
for record in records[0]['rrset_values']:
|
||||
priority, server = record.split(' ')
|
||||
values.append({
|
||||
'preference': priority,
|
||||
'exchange': server
|
||||
})
|
||||
|
||||
return {
|
||||
'ttl': records[0]['rrset_ttl'],
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
def _data_for_SRV(self, _type, records):
|
||||
values = []
|
||||
for record in records[0]['rrset_values']:
|
||||
priority, weight, port, target = record.split(' ', 3)
|
||||
values.append({
|
||||
'priority': priority,
|
||||
'weight': weight,
|
||||
'port': port,
|
||||
'target': target
|
||||
})
|
||||
|
||||
return {
|
||||
'ttl': records[0]['rrset_ttl'],
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
def _data_for_SSHFP(self, _type, records):
|
||||
values = []
|
||||
for record in records[0]['rrset_values']:
|
||||
algorithm, fingerprint_type, fingerprint = record.split(' ', 2)
|
||||
values.append({
|
||||
'algorithm': algorithm,
|
||||
'fingerprint': fingerprint,
|
||||
'fingerprint_type': fingerprint_type
|
||||
})
|
||||
|
||||
return {
|
||||
'ttl': records[0]['rrset_ttl'],
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
def zone_records(self, zone):
|
||||
if zone.name not in self._zone_records:
|
||||
try:
|
||||
self._zone_records[zone.name] = \
|
||||
self._client.zone_records(zone.name[:-1])
|
||||
except GandiClientNotFound:
|
||||
return []
|
||||
|
||||
return self._zone_records[zone.name]
|
||||
|
||||
def populate(self, zone, target=False, lenient=False):
|
||||
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
|
||||
target, lenient)
|
||||
|
||||
values = defaultdict(lambda: defaultdict(list))
|
||||
for record in self.zone_records(zone):
|
||||
_type = record['rrset_type']
|
||||
if _type not in self.SUPPORTS:
|
||||
continue
|
||||
values[record['rrset_name']][record['rrset_type']].append(record)
|
||||
|
||||
before = len(zone.records)
|
||||
for name, types in values.items():
|
||||
for _type, records in types.items():
|
||||
data_for = getattr(self, '_data_for_{}'.format(_type))
|
||||
record = Record.new(zone, name, data_for(_type, records),
|
||||
source=self, lenient=lenient)
|
||||
zone.add_record(record, lenient=lenient)
|
||||
|
||||
exists = zone.name in self._zone_records
|
||||
self.log.info('populate: found %s records, exists=%s',
|
||||
len(zone.records) - before, exists)
|
||||
return exists
|
||||
|
||||
def _record_name(self, name):
|
||||
return name if name else '@'
|
||||
|
||||
def _params_for_multiple(self, record):
|
||||
return {
|
||||
'rrset_name': self._record_name(record.name),
|
||||
'rrset_ttl': record.ttl,
|
||||
'rrset_type': record._type,
|
||||
'rrset_values': [v.replace('\\;', ';') for v in
|
||||
record.values] if record._type == 'TXT'
|
||||
else record.values
|
||||
}
|
||||
|
||||
_params_for_A = _params_for_multiple
|
||||
_params_for_AAAA = _params_for_multiple
|
||||
_params_for_NS = _params_for_multiple
|
||||
_params_for_TXT = _params_for_multiple
|
||||
_params_for_SPF = _params_for_multiple
|
||||
|
||||
def _params_for_CAA(self, record):
|
||||
return {
|
||||
'rrset_name': self._record_name(record.name),
|
||||
'rrset_ttl': record.ttl,
|
||||
'rrset_type': record._type,
|
||||
'rrset_values': ['{} {} "{}"'.format(v.flags, v.tag, v.value)
|
||||
for v in record.values]
|
||||
}
|
||||
|
||||
def _params_for_single(self, record):
|
||||
return {
|
||||
'rrset_name': self._record_name(record.name),
|
||||
'rrset_ttl': record.ttl,
|
||||
'rrset_type': record._type,
|
||||
'rrset_values': [record.value]
|
||||
}
|
||||
|
||||
_params_for_ALIAS = _params_for_single
|
||||
_params_for_CNAME = _params_for_single
|
||||
_params_for_DNAME = _params_for_single
|
||||
_params_for_PTR = _params_for_single
|
||||
|
||||
def _params_for_MX(self, record):
|
||||
return {
|
||||
'rrset_name': self._record_name(record.name),
|
||||
'rrset_ttl': record.ttl,
|
||||
'rrset_type': record._type,
|
||||
'rrset_values': ['{} {}'.format(v.preference, v.exchange)
|
||||
for v in record.values]
|
||||
}
|
||||
|
||||
def _params_for_SRV(self, record):
|
||||
return {
|
||||
'rrset_name': self._record_name(record.name),
|
||||
'rrset_ttl': record.ttl,
|
||||
'rrset_type': record._type,
|
||||
'rrset_values': ['{} {} {} {}'.format(v.priority, v.weight, v.port,
|
||||
v.target) for v in record.values]
|
||||
}
|
||||
|
||||
def _params_for_SSHFP(self, record):
|
||||
return {
|
||||
'rrset_name': self._record_name(record.name),
|
||||
'rrset_ttl': record.ttl,
|
||||
'rrset_type': record._type,
|
||||
'rrset_values': ['{} {} {}'.format(v.algorithm, v.fingerprint_type,
|
||||
v.fingerprint) for v in record.values]
|
||||
}
|
||||
|
||||
def _apply_create(self, change):
|
||||
new = change.new
|
||||
data = getattr(self, '_params_for_{}'.format(new._type))(new)
|
||||
self._client.record_create(new.zone.name[:-1], data)
|
||||
|
||||
def _apply_update(self, change):
|
||||
self._apply_delete(change)
|
||||
self._apply_create(change)
|
||||
|
||||
def _apply_delete(self, change):
|
||||
existing = change.existing
|
||||
zone = existing.zone
|
||||
self._client.record_delete(zone.name[:-1],
|
||||
self._record_name(existing.name),
|
||||
existing._type)
|
||||
|
||||
def _apply(self, plan):
|
||||
desired = plan.desired
|
||||
changes = plan.changes
|
||||
zone = desired.name[:-1]
|
||||
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
|
||||
len(changes))
|
||||
|
||||
try:
|
||||
self._client.zone(zone)
|
||||
except GandiClientNotFound:
|
||||
self.log.info('_apply: no existing zone, trying to create it')
|
||||
try:
|
||||
self._client.zone_create(zone)
|
||||
self.log.info('_apply: zone has been successfully created')
|
||||
except GandiClientNotFound:
|
||||
# We suppress existing exception before raising
|
||||
# GandiClientUnknownDomainName.
|
||||
e = GandiClientUnknownDomainName('This domain is not '
|
||||
'registered at Gandi. '
|
||||
'Please register or '
|
||||
'transfer it here '
|
||||
'to be able to manage its '
|
||||
'DNS zone.')
|
||||
e.__cause__ = None
|
||||
raise e
|
||||
|
||||
# Force records deletion to be done before creation in order to avoid
|
||||
# "CNAME record must be the only record" error when an existing CNAME
|
||||
# record is replaced by an A/AAAA record.
|
||||
changes.reverse()
|
||||
|
||||
for change in changes:
|
||||
class_name = change.__class__.__name__
|
||||
getattr(self, '_apply_{}'.format(class_name.lower()))(change)
|
||||
|
||||
# Clear out the cache if any
|
||||
self._zone_records.pop(desired.name, None)
|
||||
@@ -370,11 +370,15 @@ class OvhProvider(BaseProvider):
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_dkim_key(key):
|
||||
result = True
|
||||
base64_decode = getattr(base64, 'decodestring', None)
|
||||
base64_decode = getattr(base64, 'decodebytes', base64_decode)
|
||||
|
||||
try:
|
||||
base64.decodestring(bytearray(key, 'utf-8'))
|
||||
result = base64_decode(bytearray(key, 'utf-8'))
|
||||
except binascii.Error:
|
||||
return False
|
||||
return True
|
||||
result = False
|
||||
return result
|
||||
|
||||
def get_records(self, zone_name):
|
||||
"""
|
||||
|
||||
@@ -183,7 +183,10 @@ class PowerDnsBaseProvider(BaseProvider):
|
||||
version = resp.json()['version']
|
||||
self.log.debug('powerdns_version: got version %s from server',
|
||||
version)
|
||||
self._powerdns_version = [int(p) for p in version.split('.')]
|
||||
# The extra `-` split is to handle pre-release and source built
|
||||
# versions like 4.5.0-alpha0.435.master.gcb114252b
|
||||
self._powerdns_version = [
|
||||
int(p.split('-')[0]) for p in version.split('.')[:3]]
|
||||
|
||||
return self._powerdns_version
|
||||
|
||||
|
||||
@@ -287,7 +287,13 @@ class UltraProvider(BaseProvider):
|
||||
name = zone.hostname_from_fqdn(record['ownerName'])
|
||||
if record['rrtype'] == 'SOA (6)':
|
||||
continue
|
||||
_type = self.RECORDS_TO_TYPE[record['rrtype']]
|
||||
try:
|
||||
_type = self.RECORDS_TO_TYPE[record['rrtype']]
|
||||
except KeyError:
|
||||
self.log.warning('populate: ignoring record with '
|
||||
'unsupported rrtype, %s %s',
|
||||
name, record['rrtype'])
|
||||
continue
|
||||
values[name][_type] = record
|
||||
|
||||
for name, types in values.items():
|
||||
|
||||
@@ -104,8 +104,8 @@ class YamlProvider(BaseProvider):
|
||||
'''
|
||||
SUPPORTS_GEO = True
|
||||
SUPPORTS_DYNAMIC = True
|
||||
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS',
|
||||
'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT'))
|
||||
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', 'MX',
|
||||
'NAPTR', 'NS', 'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT'))
|
||||
|
||||
def __init__(self, id, directory, default_ttl=3600, enforce_order=True,
|
||||
populate_should_replace=False, *args, **kwargs):
|
||||
|
||||
@@ -10,6 +10,7 @@ from logging import getLogger
|
||||
import re
|
||||
|
||||
from six import string_types, text_type
|
||||
from fqdn import FQDN
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from .geo import GeoCodes
|
||||
@@ -95,6 +96,7 @@ class Record(EqualityTupleMixin):
|
||||
'ALIAS': AliasRecord,
|
||||
'CAA': CaaRecord,
|
||||
'CNAME': CnameRecord,
|
||||
'DNAME': DnameRecord,
|
||||
'MX': MxRecord,
|
||||
'NAPTR': NaptrRecord,
|
||||
'NS': NsRecord,
|
||||
@@ -125,10 +127,11 @@ class Record(EqualityTupleMixin):
|
||||
if n > 253:
|
||||
reasons.append('invalid fqdn, "{}" is too long at {} chars, max '
|
||||
'is 253'.format(fqdn, n))
|
||||
n = len(name)
|
||||
if n > 63:
|
||||
reasons.append('invalid name, "{}" is too long at {} chars, max '
|
||||
'is 63'.format(name, n))
|
||||
for label in name.split('.'):
|
||||
n = len(label)
|
||||
if n > 63:
|
||||
reasons.append('invalid label, "{}" is too long at {} chars, '
|
||||
'max is 63'.format(label, n))
|
||||
try:
|
||||
ttl = int(data['ttl'])
|
||||
if ttl < 0:
|
||||
@@ -218,6 +221,18 @@ class Record(EqualityTupleMixin):
|
||||
if self.ttl != other.ttl:
|
||||
return Update(self, other)
|
||||
|
||||
def copy(self, zone=None):
|
||||
data = self.data
|
||||
data['type'] = self._type
|
||||
|
||||
return Record.new(
|
||||
zone if zone else self.zone,
|
||||
self.name,
|
||||
data,
|
||||
self.source,
|
||||
lenient=True
|
||||
)
|
||||
|
||||
# NOTE: we're using __hash__ and ordering methods that consider Records
|
||||
# equivalent if they have the same name & _type. Values are ignored. This
|
||||
# is useful when computing diffs/changes.
|
||||
@@ -743,6 +758,9 @@ class _TargetValue(object):
|
||||
reasons.append('empty value')
|
||||
elif not data:
|
||||
reasons.append('missing value')
|
||||
elif not FQDN(data, allow_underscores=True).is_valid:
|
||||
reasons.append('{} value "{}" is not a valid FQDN'
|
||||
.format(_type, data))
|
||||
elif not data.endswith('.'):
|
||||
reasons.append('{} value "{}" missing trailing .'
|
||||
.format(_type, data))
|
||||
@@ -759,6 +777,10 @@ class CnameValue(_TargetValue):
|
||||
pass
|
||||
|
||||
|
||||
class DnameValue(_TargetValue):
|
||||
pass
|
||||
|
||||
|
||||
class ARecord(_DynamicMixin, _GeoMixin, Record):
|
||||
_type = 'A'
|
||||
_value_type = Ipv4List
|
||||
@@ -777,6 +799,14 @@ class AliasRecord(_ValueMixin, Record):
|
||||
_type = 'ALIAS'
|
||||
_value_type = AliasValue
|
||||
|
||||
@classmethod
|
||||
def validate(cls, name, fqdn, data):
|
||||
reasons = []
|
||||
if name != '':
|
||||
reasons.append('non-root ALIAS not allowed')
|
||||
reasons.extend(super(AliasRecord, cls).validate(name, fqdn, data))
|
||||
return reasons
|
||||
|
||||
|
||||
class CaaValue(EqualityTupleMixin):
|
||||
# https://tools.ietf.org/html/rfc6844#page-5
|
||||
@@ -842,6 +872,11 @@ class CnameRecord(_DynamicMixin, _ValueMixin, Record):
|
||||
return reasons
|
||||
|
||||
|
||||
class DnameRecord(_DynamicMixin, _ValueMixin, Record):
|
||||
_type = 'DNAME'
|
||||
_value_type = DnameValue
|
||||
|
||||
|
||||
class MxValue(EqualityTupleMixin):
|
||||
|
||||
@classmethod
|
||||
|
||||
+16
-1
@@ -26,7 +26,7 @@ class AxfrBaseSource(BaseSource):
|
||||
|
||||
SUPPORTS_GEO = False
|
||||
SUPPORTS_DYNAMIC = False
|
||||
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SPF',
|
||||
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'PTR', 'SPF',
|
||||
'SRV', 'TXT'))
|
||||
|
||||
def __init__(self, id):
|
||||
@@ -43,6 +43,21 @@ class AxfrBaseSource(BaseSource):
|
||||
_data_for_AAAA = _data_for_multiple
|
||||
_data_for_NS = _data_for_multiple
|
||||
|
||||
def _data_for_CAA(self, _type, records):
|
||||
values = []
|
||||
for record in records:
|
||||
flags, tag, value = record['value'].split(' ', 2)
|
||||
values.append({
|
||||
'flags': flags,
|
||||
'tag': tag,
|
||||
'value': value.replace('"', '')
|
||||
})
|
||||
return {
|
||||
'ttl': records[0]['ttl'],
|
||||
'type': _type,
|
||||
'values': values
|
||||
}
|
||||
|
||||
def _data_for_MX(self, _type, records):
|
||||
values = []
|
||||
for record in records:
|
||||
|
||||
@@ -5,4 +5,4 @@ pycodestyle==2.6.0
|
||||
pyflakes==2.2.0
|
||||
readme_renderer[md]==26.0
|
||||
requests_mock
|
||||
twine==1.15.0
|
||||
twine==3.2.0
|
||||
|
||||
+5
-4
@@ -1,14 +1,15 @@
|
||||
PyYaml==5.3.1
|
||||
azure-common==1.1.25
|
||||
azure-mgmt-dns==3.0.0
|
||||
boto3==1.14.14
|
||||
botocore==1.17.14
|
||||
boto3==1.15.9
|
||||
botocore==1.18.9
|
||||
dnspython==1.16.0
|
||||
docutils==0.16
|
||||
dyn==1.8.1
|
||||
edgegrid-python==1.1.1
|
||||
fqdn==1.5.0
|
||||
futures==3.2.0; python_version < '3.2'
|
||||
google-cloud-core==1.3.0
|
||||
google-cloud-core==1.4.1
|
||||
google-cloud-dns==0.32.0
|
||||
ipaddress==1.0.23; python_version < '3.3'
|
||||
jmespath==0.10.0
|
||||
@@ -17,7 +18,7 @@ natsort==6.2.1
|
||||
ns1-python==0.16.0
|
||||
ovh==0.5.0
|
||||
pycountry-convert==0.7.2
|
||||
pycountry==19.8.18
|
||||
pycountry==20.7.3
|
||||
python-dateutil==2.8.1
|
||||
requests==2.24.0
|
||||
s3transfer==0.3.3
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export DYN_USERNAME=
|
||||
export GOOGLE_APPLICATION_CREDENTIALS=
|
||||
|
||||
# Don't allow disabling coverage
|
||||
grep -r -I --line-number "# pragma: nocover" octodns && {
|
||||
grep -r -I --line-number "# pragma: +no.*cover" octodns && {
|
||||
echo "Code coverage should not be disabled"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -81,6 +81,6 @@ setup(
|
||||
long_description_content_type='text/markdown',
|
||||
name='octodns',
|
||||
packages=find_packages(),
|
||||
url='https://github.com/github/octodns',
|
||||
url='https://github.com/octodns/octodns',
|
||||
version=octodns.__VERSION__,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
manager:
|
||||
max_workers: 2
|
||||
providers:
|
||||
in:
|
||||
class: octodns.provider.yaml.YamlProvider
|
||||
directory: tests/config
|
||||
dump:
|
||||
class: octodns.provider.yaml.YamlProvider
|
||||
directory: env/YAML_TMP_DIR
|
||||
zones:
|
||||
unit.tests.:
|
||||
sources:
|
||||
- in
|
||||
targets:
|
||||
- dump
|
||||
|
||||
alias.tests.:
|
||||
alias: unit.tests.
|
||||
|
||||
alias-loop.tests.:
|
||||
alias: alias.tests.
|
||||
@@ -0,0 +1,19 @@
|
||||
manager:
|
||||
max_workers: 2
|
||||
providers:
|
||||
in:
|
||||
class: octodns.provider.yaml.YamlProvider
|
||||
directory: tests/config
|
||||
dump:
|
||||
class: octodns.provider.yaml.YamlProvider
|
||||
directory: env/YAML_TMP_DIR
|
||||
zones:
|
||||
unit.tests.:
|
||||
sources:
|
||||
- in
|
||||
targets:
|
||||
- dump
|
||||
|
||||
alias.tests.:
|
||||
alias: unit.tests.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
dname:
|
||||
ttl: 300
|
||||
type: DNAME
|
||||
value: unit.tests.
|
||||
@@ -56,6 +56,10 @@ cname:
|
||||
ttl: 300
|
||||
type: CNAME
|
||||
value: unit.tests.
|
||||
dname:
|
||||
ttl: 300
|
||||
type: DNAME
|
||||
value: unit.tests.
|
||||
excluded:
|
||||
octodns:
|
||||
excluded:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
manager:
|
||||
max_workers: 2
|
||||
providers:
|
||||
in:
|
||||
class: octodns.provider.yaml.YamlProvider
|
||||
directory: tests/config
|
||||
dump:
|
||||
class: octodns.provider.yaml.YamlProvider
|
||||
directory: env/YAML_TMP_DIR
|
||||
zones:
|
||||
unit.tests.:
|
||||
sources:
|
||||
- in
|
||||
targets:
|
||||
- dump
|
||||
|
||||
alias.tests.:
|
||||
alias: does-not-exists.tests.
|
||||
-37
@@ -523,43 +523,6 @@
|
||||
"roundRobinFailover": [],
|
||||
"pools": [],
|
||||
"poolsDetail": []
|
||||
}, {
|
||||
"id": 1808603,
|
||||
"type": "ANAME",
|
||||
"recordType": "aname",
|
||||
"name": "sub",
|
||||
"recordOption": "roundRobin",
|
||||
"noAnswer": false,
|
||||
"note": "",
|
||||
"ttl": 1800,
|
||||
"gtdRegion": 1,
|
||||
"parentId": 123123,
|
||||
"parent": "domain",
|
||||
"source": "Domain",
|
||||
"modifiedTs": 1565153387855,
|
||||
"value": [{
|
||||
"value": "aname.unit.tests.",
|
||||
"disableFlag": false
|
||||
}],
|
||||
"roundRobin": [{
|
||||
"value": "aname.unit.tests.",
|
||||
"disableFlag": false
|
||||
}],
|
||||
"geolocation": null,
|
||||
"recordFailover": {
|
||||
"disabled": false,
|
||||
"failoverType": 1,
|
||||
"failoverTypeStr": "Normal (always lowest level)",
|
||||
"values": []
|
||||
},
|
||||
"failover": {
|
||||
"disabled": false,
|
||||
"failoverType": 1,
|
||||
"failoverTypeStr": "Normal (always lowest level)",
|
||||
"values": []
|
||||
},
|
||||
"pools": [],
|
||||
"poolsDetail": []
|
||||
}, {
|
||||
"id": 1808520,
|
||||
"type": "A",
|
||||
|
||||
-14
@@ -320,20 +320,6 @@
|
||||
"name": "",
|
||||
"value": "aname.unit.tests.",
|
||||
"id": 11189895,
|
||||
"type": "ANAME"
|
||||
}, {
|
||||
"failover": false,
|
||||
"monitor": false,
|
||||
"sourceId": 123123,
|
||||
"dynamicDns": false,
|
||||
"failed": false,
|
||||
"gtdLocation": "DEFAULT",
|
||||
"hardLink": false,
|
||||
"ttl": 1800,
|
||||
"source": 1,
|
||||
"name": "sub",
|
||||
"value": "aname",
|
||||
"id": 11189896,
|
||||
"type": "ANAME"
|
||||
}, {
|
||||
"failover": false,
|
||||
|
||||
Vendored
+136
@@ -0,0 +1,136 @@
|
||||
[
|
||||
{
|
||||
"rrset_type": "A",
|
||||
"rrset_ttl": 300,
|
||||
"rrset_name": "@",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A",
|
||||
"rrset_values": [
|
||||
"1.2.3.4",
|
||||
"1.2.3.5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "CAA",
|
||||
"rrset_ttl": 3600,
|
||||
"rrset_name": "@",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/CAA",
|
||||
"rrset_values": [
|
||||
"0 issue \"ca.unit.tests\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "SSHFP",
|
||||
"rrset_ttl": 3600,
|
||||
"rrset_name": "@",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/SSHFP",
|
||||
"rrset_values": [
|
||||
"1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49",
|
||||
"1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "AAAA",
|
||||
"rrset_ttl": 600,
|
||||
"rrset_name": "aaaa",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/aaaa/AAAA",
|
||||
"rrset_values": [
|
||||
"2601:644:500:e210:62f8:1dff:feb8:947a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "CNAME",
|
||||
"rrset_ttl": 300,
|
||||
"rrset_name": "cname",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/cname/CNAME",
|
||||
"rrset_values": [
|
||||
"unit.tests."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "DNAME",
|
||||
"rrset_ttl": 300,
|
||||
"rrset_name": "dname",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/dname/DNAME",
|
||||
"rrset_values": [
|
||||
"unit.tests."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "CNAME",
|
||||
"rrset_ttl": 3600,
|
||||
"rrset_name": "excluded",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/excluded/CNAME",
|
||||
"rrset_values": [
|
||||
"unit.tests."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "MX",
|
||||
"rrset_ttl": 300,
|
||||
"rrset_name": "mx",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/mx/MX",
|
||||
"rrset_values": [
|
||||
"10 smtp-4.unit.tests.",
|
||||
"20 smtp-2.unit.tests.",
|
||||
"30 smtp-3.unit.tests.",
|
||||
"40 smtp-1.unit.tests."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "PTR",
|
||||
"rrset_ttl": 300,
|
||||
"rrset_name": "ptr",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/ptr/PTR",
|
||||
"rrset_values": [
|
||||
"foo.bar.com."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "SPF",
|
||||
"rrset_ttl": 600,
|
||||
"rrset_name": "spf",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/spf/SPF",
|
||||
"rrset_values": [
|
||||
"\"v=spf1 ip4:192.168.0.1/16-all\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "TXT",
|
||||
"rrset_ttl": 600,
|
||||
"rrset_name": "txt",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/txt/TXT",
|
||||
"rrset_values": [
|
||||
"\"Bah bah black sheep\"",
|
||||
"\"have you any wool.\"",
|
||||
"\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "A",
|
||||
"rrset_ttl": 300,
|
||||
"rrset_name": "www",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/A",
|
||||
"rrset_values": [
|
||||
"2.2.3.6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "A",
|
||||
"rrset_ttl": 300,
|
||||
"rrset_name": "www.sub",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www.sub/A",
|
||||
"rrset_values": [
|
||||
"2.2.3.6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "SRV",
|
||||
"rrset_ttl": 600,
|
||||
"rrset_name": "_srv._tcp",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_srv._tcp/SRV",
|
||||
"rrset_values": [
|
||||
"10 20 30 foo-1.unit.tests.",
|
||||
"12 20 30 foo-2.unit.tests."
|
||||
]
|
||||
}
|
||||
]
|
||||
Vendored
+111
@@ -0,0 +1,111 @@
|
||||
[
|
||||
{
|
||||
"rrset_type": "A",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "@",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/A",
|
||||
"rrset_values": [
|
||||
"217.70.184.38"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "MX",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "@",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/MX",
|
||||
"rrset_values": [
|
||||
"10 spool.mail.gandi.net.",
|
||||
"50 fb.mail.gandi.net."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "TXT",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "@",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/%40/TXT",
|
||||
"rrset_values": [
|
||||
"\"v=spf1 include:_mailcust.gandi.net ?all\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "CNAME",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "webmail",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/webmail/CNAME",
|
||||
"rrset_values": [
|
||||
"webmail.gandi.net."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "CNAME",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "www",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/www/CNAME",
|
||||
"rrset_values": [
|
||||
"webredir.vip.gandi.net."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "SRV",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "_imap._tcp",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imap._tcp/SRV",
|
||||
"rrset_values": [
|
||||
"0 0 0 ."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "SRV",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "_imaps._tcp",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_imaps._tcp/SRV",
|
||||
"rrset_values": [
|
||||
"0 1 993 mail.gandi.net."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "SRV",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "_pop3._tcp",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3._tcp/SRV",
|
||||
"rrset_values": [
|
||||
"0 0 0 ."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "SRV",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "_pop3s._tcp",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_pop3s._tcp/SRV",
|
||||
"rrset_values": [
|
||||
"10 1 995 mail.gandi.net."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "SRV",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "_submission._tcp",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/_submission._tcp/SRV",
|
||||
"rrset_values": [
|
||||
"0 1 465 mail.gandi.net."
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "CDS",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "sub",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/sub/CDS",
|
||||
"rrset_values": [
|
||||
"32128 13 1 6823D9BB1B03DF714DD0EB163E20B341C96D18C0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"rrset_type": "CNAME",
|
||||
"rrset_ttl": 10800,
|
||||
"rrset_name": "relative",
|
||||
"rrset_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records/relative/CNAME",
|
||||
"rrset_values": [
|
||||
"target"
|
||||
]
|
||||
}
|
||||
]
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"domain_keys_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/keys",
|
||||
"fqdn": "unit.tests",
|
||||
"automatic_snapshots": true,
|
||||
"domain_records_href": "https://api.gandi.net/v5/livedns/domains/unit.tests/records",
|
||||
"domain_href": "https://api.gandi.net/v5/livedns/domains/unit.tests"
|
||||
}
|
||||
+1
-1
@@ -87,7 +87,7 @@
|
||||
}
|
||||
],
|
||||
"resultInfo": {
|
||||
"totalCount": 12,
|
||||
"totalCount": 13,
|
||||
"offset": 0,
|
||||
"returnedCount": 10
|
||||
}
|
||||
|
||||
+10
-2
@@ -24,11 +24,19 @@
|
||||
"order": "FIXED",
|
||||
"description": "octodns1.test."
|
||||
}
|
||||
},
|
||||
{
|
||||
"ownerName": "octodns1.test.",
|
||||
"rrtype": "APEXALIAS (65282)",
|
||||
"ttl": 3600,
|
||||
"rdata": [
|
||||
"www.octodns1.test."
|
||||
]
|
||||
}
|
||||
],
|
||||
"resultInfo": {
|
||||
"totalCount": 12,
|
||||
"totalCount": 13,
|
||||
"offset": 10,
|
||||
"returnedCount": 2
|
||||
"returnedCount": 3
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"dnssecStatus": "UNSIGNED",
|
||||
"status": "ACTIVE",
|
||||
"owner": "phelpstest",
|
||||
"resourceRecordCount": 5,
|
||||
"resourceRecordCount": 6,
|
||||
"lastModifiedDateTime": "2020-06-19T01:05Z"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -118,12 +118,12 @@ class TestManager(TestCase):
|
||||
environ['YAML_TMP_DIR'] = tmpdir.dirname
|
||||
tc = Manager(get_config_filename('simple.yaml')) \
|
||||
.sync(dry_run=False)
|
||||
self.assertEquals(21, tc)
|
||||
self.assertEquals(22, tc)
|
||||
|
||||
# try with just one of the zones
|
||||
tc = Manager(get_config_filename('simple.yaml')) \
|
||||
.sync(dry_run=False, eligible_zones=['unit.tests.'])
|
||||
self.assertEquals(15, tc)
|
||||
self.assertEquals(16, tc)
|
||||
|
||||
# the subzone, with 2 targets
|
||||
tc = Manager(get_config_filename('simple.yaml')) \
|
||||
@@ -138,18 +138,26 @@ class TestManager(TestCase):
|
||||
# Again with force
|
||||
tc = Manager(get_config_filename('simple.yaml')) \
|
||||
.sync(dry_run=False, force=True)
|
||||
self.assertEquals(21, tc)
|
||||
self.assertEquals(22, tc)
|
||||
|
||||
# Again with max_workers = 1
|
||||
tc = Manager(get_config_filename('simple.yaml'), max_workers=1) \
|
||||
.sync(dry_run=False, force=True)
|
||||
self.assertEquals(21, tc)
|
||||
self.assertEquals(22, tc)
|
||||
|
||||
# Include meta
|
||||
tc = Manager(get_config_filename('simple.yaml'), max_workers=1,
|
||||
include_meta=True) \
|
||||
.sync(dry_run=False, force=True)
|
||||
self.assertEquals(25, tc)
|
||||
self.assertEquals(26, tc)
|
||||
|
||||
def test_eligible_sources(self):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
environ['YAML_TMP_DIR'] = tmpdir.dirname
|
||||
# Only allow a target that doesn't exist
|
||||
tc = Manager(get_config_filename('simple.yaml')) \
|
||||
.sync(eligible_sources=['foo'])
|
||||
self.assertEquals(0, tc)
|
||||
|
||||
def test_eligible_targets(self):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
@@ -159,6 +167,38 @@ class TestManager(TestCase):
|
||||
.sync(eligible_targets=['foo'])
|
||||
self.assertEquals(0, tc)
|
||||
|
||||
def test_aliases(self):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
environ['YAML_TMP_DIR'] = tmpdir.dirname
|
||||
# Alias zones with a valid target.
|
||||
tc = Manager(get_config_filename('simple-alias-zone.yaml')) \
|
||||
.sync()
|
||||
self.assertEquals(0, tc)
|
||||
|
||||
# Alias zone with an invalid target.
|
||||
with self.assertRaises(ManagerException) as ctx:
|
||||
tc = Manager(get_config_filename('unknown-source-zone.yaml')) \
|
||||
.sync()
|
||||
self.assertEquals('Invalid alias zone alias.tests.: source zone '
|
||||
'does-not-exists.tests. does not exist',
|
||||
text_type(ctx.exception))
|
||||
|
||||
# Alias zone that points to another alias zone.
|
||||
with self.assertRaises(ManagerException) as ctx:
|
||||
tc = Manager(get_config_filename('alias-zone-loop.yaml')) \
|
||||
.sync()
|
||||
self.assertEquals('Invalid alias zone alias-loop.tests.: source '
|
||||
'zone alias.tests. is an alias zone',
|
||||
text_type(ctx.exception))
|
||||
|
||||
# Sync an alias without the zone it refers to
|
||||
with self.assertRaises(ManagerException) as ctx:
|
||||
tc = Manager(get_config_filename('simple-alias-zone.yaml')) \
|
||||
.sync(eligible_zones=["alias.tests."])
|
||||
self.assertEquals('Zone alias.tests. cannot be sync without zone '
|
||||
'unit.tests. sinced it is aliased',
|
||||
text_type(ctx.exception))
|
||||
|
||||
def test_compare(self):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
environ['YAML_TMP_DIR'] = tmpdir.dirname
|
||||
@@ -175,13 +215,13 @@ class TestManager(TestCase):
|
||||
fh.write('---\n{}')
|
||||
|
||||
changes = manager.compare(['in'], ['dump'], 'unit.tests.')
|
||||
self.assertEquals(15, len(changes))
|
||||
self.assertEquals(16, len(changes))
|
||||
|
||||
# Compound sources with varying support
|
||||
changes = manager.compare(['in', 'nosshfp'],
|
||||
['dump'],
|
||||
'unit.tests.')
|
||||
self.assertEquals(14, len(changes))
|
||||
self.assertEquals(15, len(changes))
|
||||
|
||||
with self.assertRaises(ManagerException) as ctx:
|
||||
manager.compare(['nope'], ['dump'], 'unit.tests.')
|
||||
@@ -278,6 +318,36 @@ class TestManager(TestCase):
|
||||
.validate_configs()
|
||||
self.assertTrue('unknown source' in text_type(ctx.exception))
|
||||
|
||||
# Alias zone using an invalid source zone.
|
||||
with self.assertRaises(ManagerException) as ctx:
|
||||
Manager(get_config_filename('unknown-source-zone.yaml')) \
|
||||
.validate_configs()
|
||||
self.assertTrue('does not exist' in
|
||||
text_type(ctx.exception))
|
||||
|
||||
# Alias zone that points to another alias zone.
|
||||
with self.assertRaises(ManagerException) as ctx:
|
||||
Manager(get_config_filename('alias-zone-loop.yaml')) \
|
||||
.validate_configs()
|
||||
self.assertTrue('is an alias zone' in
|
||||
text_type(ctx.exception))
|
||||
|
||||
# Valid config file using an alias zone.
|
||||
Manager(get_config_filename('simple-alias-zone.yaml')) \
|
||||
.validate_configs()
|
||||
|
||||
def test_get_zone(self):
|
||||
Manager(get_config_filename('simple.yaml')).get_zone('unit.tests.')
|
||||
|
||||
with self.assertRaises(ManagerException) as ctx:
|
||||
Manager(get_config_filename('simple.yaml')).get_zone('unit.tests')
|
||||
self.assertTrue('missing ending dot' in text_type(ctx.exception))
|
||||
|
||||
with self.assertRaises(ManagerException) as ctx:
|
||||
Manager(get_config_filename('simple.yaml')) \
|
||||
.get_zone('unknown-zone.tests.')
|
||||
self.assertTrue('Unknown zone name' in text_type(ctx.exception))
|
||||
|
||||
def test_populate_lenient_fallback(self):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
environ['YAML_TMP_DIR'] = tmpdir.dirname
|
||||
|
||||
@@ -433,10 +433,6 @@ class TestAzureDnsProvider(TestCase):
|
||||
recordSet.name, recordSet.ttl, recordSet.type = 'cname1', 5, 'CNAME'
|
||||
recordSet.target_resource = SubResource()
|
||||
rs.append(recordSet)
|
||||
recordSet = RecordSet(cname_record=None)
|
||||
recordSet.name, recordSet.ttl, recordSet.type = 'cname2', 6, 'CNAME'
|
||||
recordSet.target_resource = SubResource()
|
||||
rs.append(recordSet)
|
||||
recordSet = RecordSet(mx_records=[MxRecord(preference=10,
|
||||
exchange='mx1.unit.test.')])
|
||||
recordSet.name, recordSet.ttl, recordSet.type = 'mx1', 7, 'MX'
|
||||
@@ -458,9 +454,6 @@ class TestAzureDnsProvider(TestCase):
|
||||
recordSet = RecordSet(ptr_records=[ptr1])
|
||||
recordSet.name, recordSet.ttl, recordSet.type = 'ptr1', 11, 'PTR'
|
||||
rs.append(recordSet)
|
||||
recordSet = RecordSet(ptr_records=[PtrRecord(ptrdname=None)])
|
||||
recordSet.name, recordSet.ttl, recordSet.type = 'ptr2', 12, 'PTR'
|
||||
rs.append(recordSet)
|
||||
recordSet = RecordSet(srv_records=[SrvRecord(priority=1,
|
||||
weight=2,
|
||||
port=3,
|
||||
@@ -508,7 +501,8 @@ class TestAzureDnsProvider(TestCase):
|
||||
exists = provider.populate(zone)
|
||||
self.assertTrue(exists)
|
||||
|
||||
self.assertEquals(len(zone.records), 19)
|
||||
|
||||
self.assertEquals(len(zone.records), 17)
|
||||
|
||||
def test_populate_zone(self):
|
||||
provider = self._get_provider()
|
||||
|
||||
@@ -42,12 +42,6 @@ class TestConstellixProvider(TestCase):
|
||||
'value': 'aname.unit.tests.'
|
||||
}))
|
||||
|
||||
expected.add_record(Record.new(expected, 'sub', {
|
||||
'ttl': 1800,
|
||||
'type': 'ALIAS',
|
||||
'value': 'aname.unit.tests.'
|
||||
}))
|
||||
|
||||
for record in list(expected.records):
|
||||
if record.name == 'sub' and record._type == 'NS':
|
||||
expected._remove_record(record)
|
||||
@@ -107,14 +101,14 @@ class TestConstellixProvider(TestCase):
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(15, len(zone.records))
|
||||
self.assertEquals(14, len(zone.records))
|
||||
changes = self.expected.changes(zone, provider)
|
||||
self.assertEquals(0, len(changes))
|
||||
|
||||
# 2nd populate makes no network calls/all from cache
|
||||
again = Zone('unit.tests.', [])
|
||||
provider.populate(again)
|
||||
self.assertEquals(15, len(again.records))
|
||||
self.assertEquals(14, len(again.records))
|
||||
|
||||
# bust the cache
|
||||
del provider._zone_records[zone.name]
|
||||
@@ -138,7 +132,7 @@ class TestConstellixProvider(TestCase):
|
||||
plan = provider.plan(self.expected)
|
||||
|
||||
# No root NS, no ignored, no excluded, no unsupported
|
||||
n = len(self.expected.records) - 5
|
||||
n = len(self.expected.records) - 6
|
||||
self.assertEquals(n, len(plan.changes))
|
||||
self.assertEquals(n, provider.apply(plan))
|
||||
|
||||
@@ -169,7 +163,7 @@ class TestConstellixProvider(TestCase):
|
||||
}),
|
||||
])
|
||||
|
||||
self.assertEquals(18, provider._client._request.call_count)
|
||||
self.assertEquals(17, provider._client._request.call_count)
|
||||
|
||||
provider._client._request.reset_mock()
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ class TestDigitalOceanProvider(TestCase):
|
||||
plan = provider.plan(self.expected)
|
||||
|
||||
# No root NS, no ignored, no excluded, no unsupported
|
||||
n = len(self.expected.records) - 7
|
||||
n = len(self.expected.records) - 8
|
||||
self.assertEquals(n, len(plan.changes))
|
||||
self.assertEquals(n, provider.apply(plan))
|
||||
self.assertFalse(plan.exists)
|
||||
|
||||
@@ -137,7 +137,7 @@ class TestDnsimpleProvider(TestCase):
|
||||
plan = provider.plan(self.expected)
|
||||
|
||||
# No root NS, no ignored, no excluded
|
||||
n = len(self.expected.records) - 3
|
||||
n = len(self.expected.records) - 4
|
||||
self.assertEquals(n, len(plan.changes))
|
||||
self.assertEquals(n, provider.apply(plan))
|
||||
self.assertFalse(plan.exists)
|
||||
|
||||
@@ -44,12 +44,6 @@ class TestDnsMadeEasyProvider(TestCase):
|
||||
'value': 'aname.unit.tests.'
|
||||
}))
|
||||
|
||||
expected.add_record(Record.new(expected, 'sub', {
|
||||
'ttl': 1800,
|
||||
'type': 'ALIAS',
|
||||
'value': 'aname.unit.tests.'
|
||||
}))
|
||||
|
||||
for record in list(expected.records):
|
||||
if record.name == 'sub' and record._type == 'NS':
|
||||
expected._remove_record(record)
|
||||
@@ -108,14 +102,14 @@ class TestDnsMadeEasyProvider(TestCase):
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(15, len(zone.records))
|
||||
self.assertEquals(14, len(zone.records))
|
||||
changes = self.expected.changes(zone, provider)
|
||||
self.assertEquals(0, len(changes))
|
||||
|
||||
# 2nd populate makes no network calls/all from cache
|
||||
again = Zone('unit.tests.', [])
|
||||
provider.populate(again)
|
||||
self.assertEquals(15, len(again.records))
|
||||
self.assertEquals(14, len(again.records))
|
||||
|
||||
# bust the cache
|
||||
del provider._zone_records[zone.name]
|
||||
@@ -140,7 +134,7 @@ class TestDnsMadeEasyProvider(TestCase):
|
||||
plan = provider.plan(self.expected)
|
||||
|
||||
# No root NS, no ignored, no excluded, no unsupported
|
||||
n = len(self.expected.records) - 5
|
||||
n = len(self.expected.records) - 6
|
||||
self.assertEquals(n, len(plan.changes))
|
||||
self.assertEquals(n, provider.apply(plan))
|
||||
|
||||
@@ -180,7 +174,7 @@ class TestDnsMadeEasyProvider(TestCase):
|
||||
'port': 30
|
||||
}),
|
||||
])
|
||||
self.assertEquals(27, provider._client._request.call_count)
|
||||
self.assertEquals(26, provider._client._request.call_count)
|
||||
|
||||
provider._client._request.reset_mock()
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ class TestEasyDNSProvider(TestCase):
|
||||
plan = provider.plan(self.expected)
|
||||
|
||||
# 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, provider.apply(plan))
|
||||
self.assertFalse(plan.exists)
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from __future__ import absolute_import, division, print_function, \
|
||||
unicode_literals
|
||||
|
||||
from mock import Mock, call
|
||||
from os.path import dirname, join
|
||||
from requests import HTTPError
|
||||
from requests_mock import ANY, mock as requests_mock
|
||||
from six import text_type
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.provider.gandi import GandiProvider, GandiClientBadRequest, \
|
||||
GandiClientUnauthorized, GandiClientForbidden, GandiClientNotFound, \
|
||||
GandiClientUnknownDomainName
|
||||
from octodns.provider.yaml import YamlProvider
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestGandiProvider(TestCase):
|
||||
expected = Zone('unit.tests.', [])
|
||||
source = YamlProvider('test', join(dirname(__file__), 'config'))
|
||||
source.populate(expected)
|
||||
|
||||
# We remove this record from the test zone as Gandi API reject it
|
||||
# (rightfully).
|
||||
expected._remove_record(Record.new(expected, 'sub', {
|
||||
'ttl': 1800,
|
||||
'type': 'NS',
|
||||
'values': [
|
||||
'6.2.3.4.',
|
||||
'7.2.3.4.'
|
||||
]
|
||||
}))
|
||||
|
||||
def test_populate(self):
|
||||
|
||||
provider = GandiProvider('test_id', 'token')
|
||||
|
||||
# 400 - Bad Request.
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=400,
|
||||
text='{"status": "error", "errors": [{"location": '
|
||||
'"body", "name": "items", "description": '
|
||||
'"\'6.2.3.4.\': invalid hostname (param: '
|
||||
'{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, '
|
||||
'\'rrset_name\': u\'sub\', \'rrset_values\': '
|
||||
'[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}, {"location": '
|
||||
'"body", "name": "items", "description": '
|
||||
'"\'7.2.3.4.\': invalid hostname (param: '
|
||||
'{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, '
|
||||
'\'rrset_name\': u\'sub\', \'rrset_values\': '
|
||||
'[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}]}')
|
||||
|
||||
with self.assertRaises(GandiClientBadRequest) as ctx:
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertIn('"status": "error"', text_type(ctx.exception))
|
||||
|
||||
# 401 - Unauthorized.
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=401,
|
||||
text='{"code":401,"message":"The server could not verify '
|
||||
'that you authorized to access the document you '
|
||||
'requested. Either you supplied the wrong '
|
||||
'credentials (e.g., bad api key), or your access '
|
||||
'token has expired","object":"HTTPUnauthorized",'
|
||||
'"cause":"Unauthorized"}')
|
||||
|
||||
with self.assertRaises(GandiClientUnauthorized) as ctx:
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertIn('"cause":"Unauthorized"', text_type(ctx.exception))
|
||||
|
||||
# 403 - Forbidden.
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=403,
|
||||
text='{"code":403,"message":"Access was denied to this '
|
||||
'resource.","object":"HTTPForbidden","cause":'
|
||||
'"Forbidden"}')
|
||||
|
||||
with self.assertRaises(GandiClientForbidden) as ctx:
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertIn('"cause":"Forbidden"', text_type(ctx.exception))
|
||||
|
||||
# 404 - Not Found.
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=404,
|
||||
text='{"code": 404, "message": "The resource could not '
|
||||
'be found.", "object": "HTTPNotFound", "cause": '
|
||||
'"Not Found"}')
|
||||
|
||||
with self.assertRaises(GandiClientNotFound) as ctx:
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider._client.zone(zone)
|
||||
self.assertIn('"cause": "Not Found"', text_type(ctx.exception))
|
||||
|
||||
# General error
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=502, text='Things caught fire')
|
||||
|
||||
with self.assertRaises(HTTPError) as ctx:
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(502, ctx.exception.response.status_code)
|
||||
|
||||
# No diffs == no changes
|
||||
with requests_mock() as mock:
|
||||
base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \
|
||||
'/records'
|
||||
with open('tests/fixtures/gandi-no-changes.json') as fh:
|
||||
mock.get(base, text=fh.read())
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(14, len(zone.records))
|
||||
changes = self.expected.changes(zone, provider)
|
||||
self.assertEquals(0, len(changes))
|
||||
|
||||
del provider._zone_records[zone.name]
|
||||
|
||||
# Default Gandi zone file.
|
||||
with requests_mock() as mock:
|
||||
base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \
|
||||
'/records'
|
||||
with open('tests/fixtures/gandi-records.json') as fh:
|
||||
mock.get(base, text=fh.read())
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
provider.populate(zone)
|
||||
self.assertEquals(11, len(zone.records))
|
||||
changes = self.expected.changes(zone, provider)
|
||||
self.assertEquals(24, len(changes))
|
||||
|
||||
# 2nd populate makes no network calls/all from cache
|
||||
again = Zone('unit.tests.', [])
|
||||
provider.populate(again)
|
||||
self.assertEquals(11, len(again.records))
|
||||
|
||||
# bust the cache
|
||||
del provider._zone_records[zone.name]
|
||||
|
||||
def test_apply(self):
|
||||
provider = GandiProvider('test_id', 'token')
|
||||
|
||||
# Zone does not exists but can be created.
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=404,
|
||||
text='{"code": 404, "message": "The resource could not '
|
||||
'be found.", "object": "HTTPNotFound", "cause": '
|
||||
'"Not Found"}')
|
||||
mock.post(ANY, status_code=201,
|
||||
text='{"message": "Domain Created"}')
|
||||
|
||||
plan = provider.plan(self.expected)
|
||||
provider.apply(plan)
|
||||
|
||||
# Zone does not exists and can't be created.
|
||||
with requests_mock() as mock:
|
||||
mock.get(ANY, status_code=404,
|
||||
text='{"code": 404, "message": "The resource could not '
|
||||
'be found.", "object": "HTTPNotFound", "cause": '
|
||||
'"Not Found"}')
|
||||
mock.post(ANY, status_code=404,
|
||||
text='{"code": 404, "message": "The resource could not '
|
||||
'be found.", "object": "HTTPNotFound", "cause": '
|
||||
'"Not Found"}')
|
||||
|
||||
with self.assertRaises((GandiClientNotFound,
|
||||
GandiClientUnknownDomainName)) as ctx:
|
||||
plan = provider.plan(self.expected)
|
||||
provider.apply(plan)
|
||||
self.assertIn('This domain is not registered at Gandi.',
|
||||
text_type(ctx.exception))
|
||||
|
||||
resp = Mock()
|
||||
resp.json = Mock()
|
||||
provider._client._request = Mock(return_value=resp)
|
||||
|
||||
with open('tests/fixtures/gandi-zone.json') as fh:
|
||||
zone = fh.read()
|
||||
|
||||
# non-existent domain
|
||||
resp.json.side_effect = [
|
||||
GandiClientNotFound(resp), # no zone in populate
|
||||
GandiClientNotFound(resp), # no domain during apply
|
||||
zone
|
||||
]
|
||||
plan = provider.plan(self.expected)
|
||||
|
||||
# No root NS, no ignored, no excluded
|
||||
n = len(self.expected.records) - 4
|
||||
self.assertEquals(n, len(plan.changes))
|
||||
self.assertEquals(n, provider.apply(plan))
|
||||
self.assertFalse(plan.exists)
|
||||
|
||||
provider._client._request.assert_has_calls([
|
||||
call('GET', '/livedns/domains/unit.tests/records'),
|
||||
call('GET', '/livedns/domains/unit.tests'),
|
||||
call('POST', '/livedns/domains', data={
|
||||
'fqdn': 'unit.tests',
|
||||
'zone': {}
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'www.sub',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'A',
|
||||
'rrset_values': ['2.2.3.6']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'www',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'A',
|
||||
'rrset_values': ['2.2.3.6']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'txt',
|
||||
'rrset_ttl': 600,
|
||||
'rrset_type': 'TXT',
|
||||
'rrset_values': [
|
||||
'Bah bah black sheep',
|
||||
'have you any wool.',
|
||||
'v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string'
|
||||
'+with+numb3rs'
|
||||
]
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'spf',
|
||||
'rrset_ttl': 600,
|
||||
'rrset_type': 'SPF',
|
||||
'rrset_values': ['v=spf1 ip4:192.168.0.1/16-all']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'ptr',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'PTR',
|
||||
'rrset_values': ['foo.bar.com.']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'mx',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'MX',
|
||||
'rrset_values': [
|
||||
'10 smtp-4.unit.tests.',
|
||||
'20 smtp-2.unit.tests.',
|
||||
'30 smtp-3.unit.tests.',
|
||||
'40 smtp-1.unit.tests.'
|
||||
]
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'excluded',
|
||||
'rrset_ttl': 3600,
|
||||
'rrset_type': 'CNAME',
|
||||
'rrset_values': ['unit.tests.']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'dname',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'DNAME',
|
||||
'rrset_values': ['unit.tests.']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'cname',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'CNAME',
|
||||
'rrset_values': ['unit.tests.']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'aaaa',
|
||||
'rrset_ttl': 600,
|
||||
'rrset_type': 'AAAA',
|
||||
'rrset_values': ['2601:644:500:e210:62f8:1dff:feb8:947a']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': '_srv._tcp',
|
||||
'rrset_ttl': 600,
|
||||
'rrset_type': 'SRV',
|
||||
'rrset_values': [
|
||||
'10 20 30 foo-1.unit.tests.',
|
||||
'12 20 30 foo-2.unit.tests.'
|
||||
]
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': '@',
|
||||
'rrset_ttl': 3600,
|
||||
'rrset_type': 'SSHFP',
|
||||
'rrset_values': [
|
||||
'1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49',
|
||||
'1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73'
|
||||
]
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': '@',
|
||||
'rrset_ttl': 3600,
|
||||
'rrset_type': 'CAA',
|
||||
'rrset_values': ['0 issue "ca.unit.tests"']
|
||||
}),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': '@',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'A',
|
||||
'rrset_values': ['1.2.3.4', '1.2.3.5']
|
||||
})
|
||||
])
|
||||
# expected number of total calls
|
||||
self.assertEquals(17, provider._client._request.call_count)
|
||||
|
||||
provider._client._request.reset_mock()
|
||||
|
||||
# delete 1 and update 1
|
||||
provider._client.zone_records = Mock(return_value=[
|
||||
{
|
||||
'rrset_name': 'www',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'A',
|
||||
'rrset_values': ['1.2.3.4']
|
||||
},
|
||||
{
|
||||
'rrset_name': 'www',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'A',
|
||||
'rrset_values': ['2.2.3.4']
|
||||
},
|
||||
{
|
||||
'rrset_name': 'ttl',
|
||||
'rrset_ttl': 600,
|
||||
'rrset_type': 'A',
|
||||
'rrset_values': ['3.2.3.4']
|
||||
}
|
||||
])
|
||||
|
||||
# Domain exists, we don't care about return
|
||||
resp.json.side_effect = ['{}']
|
||||
|
||||
wanted = Zone('unit.tests.', [])
|
||||
wanted.add_record(Record.new(wanted, 'ttl', {
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '3.2.3.4'
|
||||
}))
|
||||
|
||||
plan = provider.plan(wanted)
|
||||
self.assertTrue(plan.exists)
|
||||
self.assertEquals(2, len(plan.changes))
|
||||
self.assertEquals(2, provider.apply(plan))
|
||||
|
||||
# recreate for update, and deletes for the 2 parts of the other
|
||||
provider._client._request.assert_has_calls([
|
||||
call('DELETE', '/livedns/domains/unit.tests/records/www/A'),
|
||||
call('DELETE', '/livedns/domains/unit.tests/records/ttl/A'),
|
||||
call('POST', '/livedns/domains/unit.tests/records', data={
|
||||
'rrset_name': 'ttl',
|
||||
'rrset_ttl': 300,
|
||||
'rrset_type': 'A',
|
||||
'rrset_values': ['3.2.3.4']
|
||||
})
|
||||
], any_order=True)
|
||||
@@ -171,7 +171,7 @@ class TestMythicBeastsProvider(TestCase):
|
||||
|
||||
def test_command_generation(self):
|
||||
zone = Zone('unit.tests.', [])
|
||||
zone.add_record(Record.new(zone, 'prawf-alias', {
|
||||
zone.add_record(Record.new(zone, '', {
|
||||
'ttl': 60,
|
||||
'type': 'ALIAS',
|
||||
'value': 'alias.unit.tests.',
|
||||
@@ -228,7 +228,7 @@ class TestMythicBeastsProvider(TestCase):
|
||||
)
|
||||
|
||||
expected_commands = [
|
||||
'ADD prawf-alias.unit.tests 60 ANAME alias.unit.tests.',
|
||||
'ADD unit.tests 60 ANAME alias.unit.tests.',
|
||||
'ADD prawf-ns.unit.tests 300 NS alias.unit.tests.',
|
||||
'ADD prawf-ns.unit.tests 300 NS alias2.unit.tests.',
|
||||
'ADD prawf-a.unit.tests 60 A 1.2.3.4',
|
||||
|
||||
@@ -82,6 +82,20 @@ class TestPowerDnsProvider(TestCase):
|
||||
provider._powerdns_version = None
|
||||
self.assertNotEquals(provider.powerdns_version, [4, 1, 10])
|
||||
|
||||
# Test version detection with pre-releases
|
||||
with requests_mock() as mock:
|
||||
# Reset version, so detection will try again
|
||||
provider._powerdns_version = None
|
||||
mock.get('http://non.existent:8081/api/v1/servers/localhost',
|
||||
status_code=200, json={'version': "4.4.0-alpha1"})
|
||||
self.assertEquals(provider.powerdns_version, [4, 4, 0])
|
||||
|
||||
provider._powerdns_version = None
|
||||
mock.get('http://non.existent:8081/api/v1/servers/localhost',
|
||||
status_code=200,
|
||||
json={'version': "4.5.0-alpha0.435.master.gcb114252b"})
|
||||
self.assertEquals(provider.powerdns_version, [4, 5, 0])
|
||||
|
||||
def test_provider_version_config(self):
|
||||
provider = PowerDnsProvider('test', 'non.existent', 'api-key',
|
||||
nameserver_values=['8.8.8.8.',
|
||||
@@ -171,7 +185,7 @@ class TestPowerDnsProvider(TestCase):
|
||||
expected = Zone('unit.tests.', [])
|
||||
source = YamlProvider('test', join(dirname(__file__), 'config'))
|
||||
source.populate(expected)
|
||||
expected_n = len(expected.records) - 2
|
||||
expected_n = len(expected.records) - 3
|
||||
self.assertEquals(16, expected_n)
|
||||
|
||||
# No diffs == no changes
|
||||
@@ -277,7 +291,7 @@ class TestPowerDnsProvider(TestCase):
|
||||
expected = Zone('unit.tests.', [])
|
||||
source = YamlProvider('test', join(dirname(__file__), 'config'))
|
||||
source.populate(expected)
|
||||
self.assertEquals(18, len(expected.records))
|
||||
self.assertEquals(19, len(expected.records))
|
||||
|
||||
# A small change to a single record
|
||||
with requests_mock() as mock:
|
||||
|
||||
@@ -35,7 +35,7 @@ class TestYamlProvider(TestCase):
|
||||
|
||||
# without it we see everything
|
||||
source.populate(zone)
|
||||
self.assertEquals(18, len(zone.records))
|
||||
self.assertEquals(19, len(zone.records))
|
||||
|
||||
source.populate(dynamic_zone)
|
||||
self.assertEquals(5, len(dynamic_zone.records))
|
||||
@@ -58,12 +58,12 @@ class TestYamlProvider(TestCase):
|
||||
|
||||
# We add everything
|
||||
plan = target.plan(zone)
|
||||
self.assertEquals(15, len([c for c in plan.changes
|
||||
self.assertEquals(16, len([c for c in plan.changes
|
||||
if isinstance(c, Create)]))
|
||||
self.assertFalse(isfile(yaml_file))
|
||||
|
||||
# Now actually do it
|
||||
self.assertEquals(15, target.apply(plan))
|
||||
self.assertEquals(16, target.apply(plan))
|
||||
self.assertTrue(isfile(yaml_file))
|
||||
|
||||
# Dynamic plan
|
||||
@@ -87,7 +87,7 @@ class TestYamlProvider(TestCase):
|
||||
|
||||
# A 2nd sync should still create everything
|
||||
plan = target.plan(zone)
|
||||
self.assertEquals(15, len([c for c in plan.changes
|
||||
self.assertEquals(16, len([c for c in plan.changes
|
||||
if isinstance(c, Create)]))
|
||||
|
||||
with open(yaml_file) as fh:
|
||||
@@ -109,6 +109,7 @@ class TestYamlProvider(TestCase):
|
||||
# these are stored as singular 'value'
|
||||
self.assertTrue('value' in data.pop('aaaa'))
|
||||
self.assertTrue('value' in data.pop('cname'))
|
||||
self.assertTrue('value' in data.pop('dname'))
|
||||
self.assertTrue('value' in data.pop('included'))
|
||||
self.assertTrue('value' in data.pop('ptr'))
|
||||
self.assertTrue('value' in data.pop('spf'))
|
||||
@@ -237,7 +238,7 @@ class TestSplitYamlProvider(TestCase):
|
||||
|
||||
# without it we see everything
|
||||
source.populate(zone)
|
||||
self.assertEquals(18, len(zone.records))
|
||||
self.assertEquals(19, len(zone.records))
|
||||
|
||||
source.populate(dynamic_zone)
|
||||
self.assertEquals(5, len(dynamic_zone.records))
|
||||
@@ -251,12 +252,12 @@ class TestSplitYamlProvider(TestCase):
|
||||
|
||||
# We add everything
|
||||
plan = target.plan(zone)
|
||||
self.assertEquals(15, len([c for c in plan.changes
|
||||
self.assertEquals(16, len([c for c in plan.changes
|
||||
if isinstance(c, Create)]))
|
||||
self.assertFalse(isdir(zone_dir))
|
||||
|
||||
# Now actually do it
|
||||
self.assertEquals(15, target.apply(plan))
|
||||
self.assertEquals(16, target.apply(plan))
|
||||
|
||||
# Dynamic plan
|
||||
plan = target.plan(dynamic_zone)
|
||||
@@ -279,7 +280,7 @@ class TestSplitYamlProvider(TestCase):
|
||||
|
||||
# A 2nd sync should still create everything
|
||||
plan = target.plan(zone)
|
||||
self.assertEquals(15, len([c for c in plan.changes
|
||||
self.assertEquals(16, len([c for c in plan.changes
|
||||
if isinstance(c, Create)]))
|
||||
|
||||
yaml_file = join(zone_dir, '$unit.tests.yaml')
|
||||
@@ -302,8 +303,8 @@ class TestSplitYamlProvider(TestCase):
|
||||
self.assertTrue('values' in data.pop(record_name))
|
||||
|
||||
# These are stored as singular "value." Again, check each file.
|
||||
for record_name in ('aaaa', 'cname', 'included', 'ptr', 'spf',
|
||||
'www.sub', 'www'):
|
||||
for record_name in ('aaaa', 'cname', 'dname', 'included', 'ptr',
|
||||
'spf', 'www.sub', 'www'):
|
||||
yaml_file = join(zone_dir, '{}.yaml'.format(record_name))
|
||||
self.assertTrue(isfile(yaml_file))
|
||||
with open(yaml_file) as fh:
|
||||
@@ -387,7 +388,7 @@ class TestOverridingYamlProvider(TestCase):
|
||||
base.populate(zone)
|
||||
got = {r.name: r for r in zone.records}
|
||||
self.assertEquals(5, len(got))
|
||||
# We get the "dynamic" A from the bae config
|
||||
# We get the "dynamic" A from the base config
|
||||
self.assertTrue('dynamic' in got['a'].data)
|
||||
# No added
|
||||
self.assertFalse('added' in got)
|
||||
|
||||
@@ -9,10 +9,10 @@ from six import text_type
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import ARecord, AaaaRecord, AliasRecord, CaaRecord, \
|
||||
CaaValue, CnameRecord, Create, Delete, GeoValue, MxRecord, MxValue, \
|
||||
NaptrRecord, NaptrValue, NsRecord, PtrRecord, Record, SshfpRecord, \
|
||||
SshfpValue, SpfRecord, SrvRecord, SrvValue, TxtRecord, Update, \
|
||||
ValidationError, _Dynamic, _DynamicPool, _DynamicRule
|
||||
CaaValue, CnameRecord, DnameRecord, Create, Delete, GeoValue, MxRecord, \
|
||||
MxValue, NaptrRecord, NaptrValue, NsRecord, PtrRecord, Record, \
|
||||
SshfpRecord, SshfpValue, SpfRecord, SrvRecord, SrvValue, TxtRecord, \
|
||||
Update, ValidationError, _Dynamic, _DynamicPool, _DynamicRule
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import DynamicProvider, GeoProvider, SimpleProvider
|
||||
@@ -55,6 +55,19 @@ class TestRecord(TestCase):
|
||||
})
|
||||
self.assertEquals(upper_record.value, lower_record.value)
|
||||
|
||||
def test_dname_lowering_value(self):
|
||||
upper_record = DnameRecord(self.zone, 'DnameUppwerValue', {
|
||||
'ttl': 30,
|
||||
'type': 'DNAME',
|
||||
'value': 'GITHUB.COM',
|
||||
})
|
||||
lower_record = DnameRecord(self.zone, 'DnameLowerValue', {
|
||||
'ttl': 30,
|
||||
'type': 'DNAME',
|
||||
'value': 'github.com',
|
||||
})
|
||||
self.assertEquals(upper_record.value, lower_record.value)
|
||||
|
||||
def test_ptr_lowering_value(self):
|
||||
upper_record = PtrRecord(self.zone, 'PtrUppwerValue', {
|
||||
'ttl': 30,
|
||||
@@ -362,6 +375,10 @@ class TestRecord(TestCase):
|
||||
self.assertSingleValue(CnameRecord, 'target.foo.com.',
|
||||
'other.foo.com.')
|
||||
|
||||
def test_dname(self):
|
||||
self.assertSingleValue(DnameRecord, 'target.foo.com.',
|
||||
'other.foo.com.')
|
||||
|
||||
def test_mx(self):
|
||||
a_values = [{
|
||||
'preference': 10,
|
||||
@@ -796,6 +813,39 @@ class TestRecord(TestCase):
|
||||
})
|
||||
self.assertTrue('Unknown record type' in text_type(ctx.exception))
|
||||
|
||||
def test_record_copy(self):
|
||||
a = Record.new(self.zone, 'a', {
|
||||
'ttl': 44,
|
||||
'type': 'A',
|
||||
'value': '1.2.3.4',
|
||||
})
|
||||
|
||||
# Identical copy.
|
||||
b = a.copy()
|
||||
self.assertIsInstance(b, ARecord)
|
||||
self.assertEquals('unit.tests.', b.zone.name)
|
||||
self.assertEquals('a', b.name)
|
||||
self.assertEquals('A', b._type)
|
||||
self.assertEquals(['1.2.3.4'], b.values)
|
||||
|
||||
# Copy with another zone object.
|
||||
c_zone = Zone('other.tests.', [])
|
||||
c = a.copy(c_zone)
|
||||
self.assertIsInstance(c, ARecord)
|
||||
self.assertEquals('other.tests.', c.zone.name)
|
||||
self.assertEquals('a', c.name)
|
||||
self.assertEquals('A', c._type)
|
||||
self.assertEquals(['1.2.3.4'], c.values)
|
||||
|
||||
# Record with no record type specified in data.
|
||||
d_data = {
|
||||
'ttl': 600,
|
||||
'values': ['just a test']
|
||||
}
|
||||
d = TxtRecord(self.zone, 'txt', d_data)
|
||||
d.copy()
|
||||
self.assertEquals('TXT', d._type)
|
||||
|
||||
def test_change(self):
|
||||
existing = Record.new(self.zone, 'txt', {
|
||||
'ttl': 44,
|
||||
@@ -1265,7 +1315,7 @@ class TestRecordValidation(TestCase):
|
||||
self.assertTrue(reason.endswith('.unit.tests." is too long at 254'
|
||||
' chars, max is 253'))
|
||||
|
||||
# label length, DNS defins max as 63
|
||||
# label length, DNS defines max as 63
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
# The . will put this over the edge
|
||||
name = 'x' * 64
|
||||
@@ -1275,10 +1325,30 @@ class TestRecordValidation(TestCase):
|
||||
'value': '1.2.3.4',
|
||||
})
|
||||
reason = ctx.exception.reasons[0]
|
||||
self.assertTrue(reason.startswith('invalid name, "xxxx'))
|
||||
self.assertTrue(reason.startswith('invalid label, "xxxx'))
|
||||
self.assertTrue(reason.endswith('xxx" is too long at 64'
|
||||
' chars, max is 63'))
|
||||
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
name = 'foo.' + 'x' * 64 + '.bar'
|
||||
Record.new(self.zone, name, {
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '1.2.3.4',
|
||||
})
|
||||
reason = ctx.exception.reasons[0]
|
||||
self.assertTrue(reason.startswith('invalid label, "xxxx'))
|
||||
self.assertTrue(reason.endswith('xxx" is too long at 64'
|
||||
' chars, max is 63'))
|
||||
|
||||
# should not raise with dots
|
||||
name = 'xxxxxxxx.' * 10
|
||||
Record.new(self.zone, name, {
|
||||
'ttl': 300,
|
||||
'type': 'A',
|
||||
'value': '1.2.3.4',
|
||||
})
|
||||
|
||||
# no ttl
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {
|
||||
@@ -1693,6 +1763,16 @@ class TestRecordValidation(TestCase):
|
||||
'value': 'foo.bar.com.',
|
||||
})
|
||||
|
||||
# root only
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, 'nope', {
|
||||
'type': 'ALIAS',
|
||||
'ttl': 600,
|
||||
'value': 'foo.bar.com.',
|
||||
})
|
||||
self.assertEquals(['non-root ALIAS not allowed'],
|
||||
ctx.exception.reasons)
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {
|
||||
@@ -1703,7 +1783,7 @@ class TestRecordValidation(TestCase):
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, 'www', {
|
||||
Record.new(self.zone, '', {
|
||||
'type': 'ALIAS',
|
||||
'ttl': 600,
|
||||
'value': None
|
||||
@@ -1712,13 +1792,23 @@ class TestRecordValidation(TestCase):
|
||||
|
||||
# empty value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, 'www', {
|
||||
Record.new(self.zone, '', {
|
||||
'type': 'ALIAS',
|
||||
'ttl': 600,
|
||||
'value': ''
|
||||
})
|
||||
self.assertEquals(['empty value'], ctx.exception.reasons)
|
||||
|
||||
# not a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {
|
||||
'type': 'ALIAS',
|
||||
'ttl': 600,
|
||||
'value': '__.',
|
||||
})
|
||||
self.assertEquals(['ALIAS value "__." is not a valid FQDN'],
|
||||
ctx.exception.reasons)
|
||||
|
||||
# missing trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {
|
||||
@@ -1815,6 +1905,16 @@ class TestRecordValidation(TestCase):
|
||||
})
|
||||
self.assertEquals(['root CNAME not allowed'], ctx.exception.reasons)
|
||||
|
||||
# not a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, 'www', {
|
||||
'type': 'CNAME',
|
||||
'ttl': 600,
|
||||
'value': '___.',
|
||||
})
|
||||
self.assertEquals(['CNAME value "___." is not a valid FQDN'],
|
||||
ctx.exception.reasons)
|
||||
|
||||
# missing trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, 'www', {
|
||||
@@ -1825,6 +1925,41 @@ class TestRecordValidation(TestCase):
|
||||
self.assertEquals(['CNAME value "foo.bar.com" missing trailing .'],
|
||||
ctx.exception.reasons)
|
||||
|
||||
def test_DNAME(self):
|
||||
# A valid DNAME record.
|
||||
Record.new(self.zone, 'sub', {
|
||||
'type': 'DNAME',
|
||||
'ttl': 600,
|
||||
'value': 'foo.bar.com.',
|
||||
})
|
||||
|
||||
# A DNAME record can be present at the zone APEX.
|
||||
Record.new(self.zone, '', {
|
||||
'type': 'DNAME',
|
||||
'ttl': 600,
|
||||
'value': 'foo.bar.com.',
|
||||
})
|
||||
|
||||
# not a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, 'www', {
|
||||
'type': 'DNAME',
|
||||
'ttl': 600,
|
||||
'value': '.',
|
||||
})
|
||||
self.assertEquals(['DNAME value "." is not a valid FQDN'],
|
||||
ctx.exception.reasons)
|
||||
|
||||
# missing trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, 'www', {
|
||||
'type': 'DNAME',
|
||||
'ttl': 600,
|
||||
'value': 'foo.bar.com',
|
||||
})
|
||||
self.assertEquals(['DNAME value "foo.bar.com" missing trailing .'],
|
||||
ctx.exception.reasons)
|
||||
|
||||
def test_MX(self):
|
||||
# doesn't blow up
|
||||
Record.new(self.zone, '', {
|
||||
@@ -1998,6 +2133,16 @@ class TestRecordValidation(TestCase):
|
||||
})
|
||||
self.assertEquals(['missing value'], ctx.exception.reasons)
|
||||
|
||||
# not a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {
|
||||
'type': 'PTR',
|
||||
'ttl': 600,
|
||||
'value': '_.',
|
||||
})
|
||||
self.assertEquals(['PTR value "_." is not a valid FQDN'],
|
||||
ctx.exception.reasons)
|
||||
|
||||
# no trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {
|
||||
|
||||
@@ -34,7 +34,7 @@ class TestAxfrSource(TestCase):
|
||||
]
|
||||
|
||||
self.source.populate(got)
|
||||
self.assertEquals(11, len(got.records))
|
||||
self.assertEquals(12, len(got.records))
|
||||
|
||||
with self.assertRaises(AxfrSourceZoneTransferFailed) as ctx:
|
||||
zone = Zone('unit.tests.', [])
|
||||
@@ -50,12 +50,12 @@ class TestZoneFileSource(TestCase):
|
||||
# Valid zone file in directory
|
||||
valid = Zone('unit.tests.', [])
|
||||
self.source.populate(valid)
|
||||
self.assertEquals(11, len(valid.records))
|
||||
self.assertEquals(12, len(valid.records))
|
||||
|
||||
# 2nd populate does not read file again
|
||||
again = Zone('unit.tests.', [])
|
||||
self.source.populate(again)
|
||||
self.assertEquals(11, len(again.records))
|
||||
self.assertEquals(12, len(again.records))
|
||||
|
||||
# bust the cache
|
||||
del self.source._zone_records[valid.name]
|
||||
|
||||
@@ -13,6 +13,10 @@ $ORIGIN unit.tests.
|
||||
under 3600 IN NS ns1.unit.tests.
|
||||
under 3600 IN NS ns2.unit.tests.
|
||||
|
||||
; CAA Records
|
||||
caa 1800 IN CAA 0 issue "ca.unit.tests"
|
||||
caa 1800 IN CAA 0 iodef "mailto:[email protected]"
|
||||
|
||||
; SRV Records
|
||||
_srv._tcp 600 IN SRV 10 20 30 foo-1.unit.tests.
|
||||
_srv._tcp 600 IN SRV 10 20 30 foo-2.unit.tests.
|
||||
|
||||
Reference in New Issue
Block a user