diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 726e2e8..0f62f96 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a92a9..fb68e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea891ac..019caa3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/MANIFEST.in b/MANIFEST.in index cda90ed..9e3dc38 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 * diff --git a/README.md b/README.md index 995776a..871a35e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + ## DNS as code - Tools for managing DNS across multiple providers @@ -79,6 +79,9 @@ zones: targets: - dyn - route53 + + example.net: + alias: example.com. ``` `class` is a special key that tells OctoDNS what python class should be loaded. Any other keys will be passed as configuration values to that provider. In general any sensitive or frequently rotated values should come from environmental variables. When OctoDNS sees a value that starts with `env/` it will look for that value in the process's environment and pass the result along. @@ -87,6 +90,8 @@ Further information can be found in the `docstring` of each source and provider The `max_workers` key in the `manager` section of the config enables threading to parallelize the planning portion of the sync. +In this example, `example.net` is an alias of zone `example.com`, which means they share the same sources and targets. They will therefore have identical records. + Now that we have something to tell OctoDNS about our providers & zones we need to tell it about or records. We'll keep it simple for now and just create a single `A` record at the top-level of the domain. `config/example.com.yaml` @@ -186,11 +191,13 @@ The above command pulled the existing data out of Route53 and placed the results | [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted | | [DnsimpleProvider](/octodns/provider/dnsimple.py) | | All | No | CAA tags restricted | | [DynProvider](/octodns/provider/dyn.py) | dyn | All | Both | | +| [EasyDNSProvider](/octodns/provider/easydns.py) | | A, AAAA, CAA, CNAME, MX, NAPTR, NS, SRV, TXT | No | | | [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | A, AAAA, ALIAS, CNAME | No | | | [EnvVarSource](/octodns/source/envvar.py) | | TXT | No | read-only environment variable injection | +| [GandiProvider](/octodns/provider/gandi.py) | | A, AAAA, ALIAS, CAA, CNAME, DNAME, MX, NS, PTR, SPF, SRV, SSHFP, TXT | No | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | | -| [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | No CNAME support, missing `NA` geo target | +| [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | Missing `NA` geo target | | [OVH](/octodns/provider/ovh.py) | ovh | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT, DKIM | No | | | [PowerDnsProvider](/octodns/provider/powerdns.py) | | All | No | | | [Rackspace](/octodns/provider/rackspace.py) | | A, AAAA, ALIAS, CNAME, MX, NS, PTR, SPF, TXT | No | | @@ -198,8 +205,8 @@ The above command pulled the existing data out of Route53 and placed the results | [Selectel](/octodns/provider/selectel.py) | | A, AAAA, CNAME, MX, NS, SPF, SRV, TXT | No | | | [Transip](/octodns/provider/transip.py) | transip | A, AAAA, CNAME, MX, SRV, SPF, TXT, SSHFP, CAA | No | | | [UltraDns](/octodns/provider/ultra.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | | -| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only | -| [ZoneFileSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only | +| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only | +| [ZoneFileSource](/octodns/source/axfr.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only | | [TinyDnsFileSource](/octodns/source/tinydns.py) | | A, CNAME, MX, NS, PTR | No | read-only | | [YamlProvider](/octodns/provider/yaml.py) | | All | Yes | config | @@ -277,13 +284,13 @@ Please see our [contributing document](/CONTRIBUTING.md) if you would like to pa ## Getting help -If you have a problem or suggestion, please [open an issue](https://github.com/github/octodns/issues/new) in this repository, and we will do our best to help. Please note that this project adheres to the [Contributor Covenant Code of Conduct](/CODE_OF_CONDUCT.md). +If you have a problem or suggestion, please [open an issue](https://github.com/octodns/octodns/issues/new) in this repository, and we will do our best to help. Please note that this project adheres to the [Contributor Covenant Code of Conduct](/CODE_OF_CONDUCT.md). ## License OctoDNS is licensed under the [MIT license](LICENSE). -The MIT license grant is not for GitHub's trademarks, which include the logo designs. GitHub reserves all trademark and copyright rights in and to all GitHub trademarks. GitHub's logos include, for instance, the stylized designs that include "logo" in the file title in the following folder: https://github.com/github/octodns/tree/master/docs/logos/ +The MIT license grant is not for GitHub's trademarks, which include the logo designs. GitHub reserves all trademark and copyright rights in and to all GitHub trademarks. GitHub's logos include, for instance, the stylized designs that include "logo" in the file title in the following folder: https://github.com/octodns/octodns/tree/master/docs/logos/ GitHub® and its stylized versions and the Invertocat mark are GitHub's Trademarks or registered Trademarks. When using GitHub's logos, be sure to follow the GitHub logo guidelines. diff --git a/docs/geo_records.md b/docs/geo_records.md index ba99260..3777564 100644 --- a/docs/geo_records.md +++ b/docs/geo_records.md @@ -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. diff --git a/docs/records.md b/docs/records.md index 609383c..4cf1e4b 100644 --- a/docs/records.md +++ b/docs/records.md @@ -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 +```` diff --git a/octodns/__init__.py b/octodns/__init__.py index 341f51e..3fcdaa1 100644 --- a/octodns/__init__.py +++ b/octodns/__init__.py @@ -3,4 +3,4 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -__VERSION__ = '0.9.10' +__VERSION__ = '0.9.11' diff --git a/octodns/cmds/compare.py b/octodns/cmds/compare.py index 4123643..9bf9f1c 100755 --- a/octodns/cmds/compare.py +++ b/octodns/cmds/compare.py @@ -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() diff --git a/octodns/cmds/report.py b/octodns/cmds/report.py index 3a26052..d0b82c0 100755 --- a/octodns/cmds/report.py +++ b/octodns/cmds/report.py @@ -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) diff --git a/octodns/cmds/sync.py b/octodns/cmds/sync.py index 60793e7..dbf4103 100755 --- a/octodns/cmds/sync.py +++ b/octodns/cmds/sync.py @@ -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__': diff --git a/octodns/manager.py b/octodns/manager.py index 0665938..9ce10ff 100644 --- a/octodns/manager.py +++ b/octodns/manager.py @@ -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)) diff --git a/octodns/provider/azuredns.py b/octodns/provider/azuredns.py index 6fcf015..8ad6dd9 100644 --- a/octodns/provider/azuredns.py +++ b/octodns/provider/azuredns.py @@ -416,14 +416,8 @@ class AzureProvider(BaseProvider): :type azrecord: azure.mgmt.dns.models.RecordSet :type return: dict - - CNAME and PTR both use the catch block to catch possible empty - records. Refer to population comment. ''' - try: - return {'value': _check_endswith_dot(azrecord.cname_record.cname)} - except: - return {'value': '.'} + return {'value': _check_endswith_dot(azrecord.cname_record.cname)} def _data_for_MX(self, azrecord): return {'values': [{'preference': ar.preference, @@ -435,11 +429,8 @@ class AzureProvider(BaseProvider): return {'values': [_check_endswith_dot(val) for val in vals]} def _data_for_PTR(self, azrecord): - try: - ptrdname = azrecord.ptr_records[0].ptrdname - return {'value': _check_endswith_dot(ptrdname)} - except: - return {'value': '.'} + ptrdname = azrecord.ptr_records[0].ptrdname + return {'value': _check_endswith_dot(ptrdname)} def _data_for_SRV(self, azrecord): return {'values': [{'priority': ar.priority, 'weight': ar.weight, diff --git a/octodns/provider/easydns.py b/octodns/provider/easydns.py new file mode 100644 index 0000000..835fcb9 --- /dev/null +++ b/octodns/provider/easydns.py @@ -0,0 +1,445 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from collections import defaultdict +from requests import Session +from time import sleep +import logging +import base64 + +from ..record import Record +from .base import BaseProvider + + +class EasyDNSClientException(Exception): + pass + + +class EasyDNSClientBadRequest(EasyDNSClientException): + + def __init__(self): + super(EasyDNSClientBadRequest, self).__init__('Bad request') + + +class EasyDNSClientNotFound(EasyDNSClientException): + + def __init__(self): + super(EasyDNSClientNotFound, self).__init__('Not Found') + + +class EasyDNSClientUnauthorized(EasyDNSClientException): + + def __init__(self): + super(EasyDNSClientUnauthorized, self).__init__('Unauthorized') + + +class EasyDNSClient(object): + # EasyDNS Sandbox API + SANDBOX = 'https://sandbox.rest.easydns.net' + # EasyDNS Live API + LIVE = 'https://rest.easydns.net' + # Default Currency CAD + default_currency = 'CAD' + # Domain Portfolio + domain_portfolio = 'myport' + + def __init__(self, token, api_key, currency, portfolio, sandbox): + self.log = logging.getLogger('EasyDNSProvider[{}]'.format(id)) + self.token = token + self.api_key = api_key + self.default_currency = currency + self.domain_portfolio = portfolio + self.apienv = 'sandbox' if sandbox else 'live' + auth_key = '{}:{}'.format(self.token, self.api_key) + self.auth_key = base64.b64encode(auth_key.encode("utf-8")) + self.base_path = self.SANDBOX if sandbox else self.LIVE + sess = Session() + sess.headers.update({'Authorization': 'Basic {}' + .format(self.auth_key)}) + sess.headers.update({'accept': 'application/json'}) + self._sess = sess + + def _request(self, method, path, params=None, data=None): + url = '{}{}'.format(self.base_path, path) + resp = self._sess.request(method, url, params=params, json=data) + if resp.status_code == 400: + self.log.debug('Response code 400, path=%s', path) + if method == 'GET' and path[:8] == '/domain/': + raise EasyDNSClientNotFound() + raise EasyDNSClientBadRequest() + if resp.status_code == 401: + raise EasyDNSClientUnauthorized() + if resp.status_code == 403 or resp.status_code == 404: + raise EasyDNSClientNotFound() + resp.raise_for_status() + return resp + + def domain(self, name): + path = '/domain/{}'.format(name) + return self._request('GET', path).json() + + def domain_create(self, name): + # EasyDNS allows for new domains to be created for the purpose of DNS + # only, or with domain registration. This function creates a DNS only + # record expectig the domain to be registered already + path = '/domains/add/{}'.format(name) + domain_data = {'service': 'dns', + 'term': 1, + 'dns_only': 1, + 'portfolio': self.domain_portfolio, + 'currency': self.default_currency} + self._request('PUT', path, data=domain_data).json() + + # EasyDNS creates default records for MX, A and CNAME for new domains, + # we need to delete those default record so we can sync with the source + # records, first we'll sleep for a second before gathering new records + # We also create default NS records, but they won't be deleted + sleep(1) + records = self.records(name, True) + for record in records: + if record['host'] in ('', 'www') \ + and record['type'] in ('A', 'MX', 'CNAME'): + self.record_delete(name, record['id']) + + def records(self, zone_name, raw=False): + if raw: + path = '/zones/records/all/{}'.format(zone_name) + else: + path = '/zones/records/parsed/{}'.format(zone_name) + + ret = [] + resp = self._request('GET', path).json() + ret += resp['data'] + + for record in ret: + # change any apex record to empty string + if record['host'] == '@': + record['host'] = '' + + # change any apex value to zone name + if record['rdata'] == '@': + record['rdata'] = '{}.'.format(zone_name) + + return ret + + def record_create(self, zone_name, params): + path = '/zones/records/add/{}/{}'.format(zone_name, params['type']) + # change empty name string to @, EasyDNS uses @ for apex record names + params['host'] = params['name'] + if params['host'] == '': + params['host'] = '@' + self._request('PUT', path, data=params) + + def record_delete(self, zone_name, record_id): + path = '/zones/records/{}/{}'.format(zone_name, record_id) + self._request('DELETE', path) + + +class EasyDNSProvider(BaseProvider): + ''' + EasyDNS provider using API v3 + + easydns: + class: octodns.provider.easydns.EasyDNSProvider + # Your EasyDNS API token (required) + token: foo + # Your EasyDNS API Key (required) + api_key: bar + # Use SandBox or Live environment, optional, defaults to live + sandbox: False + # Currency to use for creating domains, default CAD + default_currency: CAD + # Domain Portfolio under which to create domains + portfolio: myport + ''' + SUPPORTS_GEO = False + SUPPORTS_DYNAMIC = False + SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'TXT', + 'SRV', 'NAPTR')) + + def __init__(self, id, token, api_key, currency='CAD', portfolio='myport', + sandbox=False, *args, **kwargs): + self.log = logging.getLogger('EasyDNSProvider[{}]'.format(id)) + self.log.debug('__init__: id=%s, token=***', id) + super(EasyDNSProvider, self).__init__(id, *args, **kwargs) + self._client = EasyDNSClient(token, api_key, currency, portfolio, + sandbox) + self._zone_records = {} + + def _data_for_multiple(self, _type, records): + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': [r['rdata'] for r in records] + } + + _data_for_A = _data_for_multiple + _data_for_AAAA = _data_for_multiple + + def _data_for_CAA(self, _type, records): + values = [] + for record in records: + try: + flags, tag, value = record['rdata'].split(' ', 2) + except ValueError: + continue + values.append({ + 'flags': flags, + 'tag': tag, + 'value': value, + }) + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': values + } + + def _data_for_NAPTR(self, _type, records): + values = [] + for record in records: + try: + order, preference, flags, service, regexp, replacement = \ + record['rdata'].split(' ', 5) + except ValueError: + continue + values.append({ + 'flags': flags[1:-1], + 'order': order, + 'preference': preference, + 'regexp': regexp[1:-1], + 'replacement': replacement, + 'service': service[1:-1], + }) + return { + 'type': _type, + 'ttl': records[0]['ttl'], + 'values': values + } + + def _data_for_CNAME(self, _type, records): + record = records[0] + return { + 'ttl': record['ttl'], + 'type': _type, + 'value': '{}'.format(record['rdata']) + } + + def _data_for_MX(self, _type, records): + values = [] + for record in records: + values.append({ + 'preference': record['prio'], + 'exchange': '{}'.format(record['rdata']) + }) + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': values + } + + def _data_for_NS(self, _type, records): + values = [] + for record in records: + data = '{}'.format(record['rdata']) + values.append(data) + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': values, + } + + def _data_for_SRV(self, _type, records): + values = [] + record = records[0] + for record in records: + try: + priority, weight, port, target = record['rdata'].split(' ', 3) + except ValueError: + rdata = record['rdata'].split(' ', 3) + priority = 0 + weight = 0 + port = 0 + target = '' + if len(rdata) != 0 and rdata[0] != '': + priority = rdata[0] + if len(rdata) >= 2: + weight = rdata[1] + if len(rdata) >= 3: + port = rdata[2] + values.append({ + 'port': int(port), + 'priority': int(priority), + 'target': target, + 'weight': int(weight) + }) + return { + 'type': _type, + 'ttl': records[0]['ttl'], + 'values': values + } + + def _data_for_TXT(self, _type, records): + values = ['"' + value['rdata'].replace(';', '\\;') + + '"' for value in records] + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': values + } + + def zone_records(self, zone): + if zone.name not in self._zone_records: + try: + self._zone_records[zone.name] = \ + self._client.records(zone.name[:-1]) + except EasyDNSClientNotFound: + return [] + + return self._zone_records[zone.name] + + def populate(self, zone, target=False, lenient=False): + self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, + target, lenient) + + values = defaultdict(lambda: defaultdict(list)) + for record in self.zone_records(zone): + _type = record['type'] + if _type not in self.SUPPORTS: + self.log.warning('populate: skipping unsupported %s record', + _type) + continue + values[record['host']][record['type']].append(record) + + before = len(zone.records) + for name, types in values.items(): + for _type, records in types.items(): + data_for = getattr(self, '_data_for_{}'.format(_type)) + record = Record.new(zone, name, data_for(_type, records), + source=self, lenient=lenient) + zone.add_record(record, lenient=lenient) + + exists = zone.name in self._zone_records + self.log.info('populate: found %s records, exists=%s', + len(zone.records) - before, exists) + return exists + + def _params_for_multiple(self, record): + for value in record.values: + yield { + 'rdata': value, + 'name': record.name, + 'ttl': record.ttl, + 'type': record._type + } + + _params_for_A = _params_for_multiple + _params_for_AAAA = _params_for_multiple + _params_for_NS = _params_for_multiple + + def _params_for_CAA(self, record): + for value in record.values: + yield { + 'rdata': "{} {} {}".format(value.flags, value.tag, + value.value), + 'name': record.name, + 'ttl': record.ttl, + 'type': record._type + } + + def _params_for_NAPTR(self, record): + for value in record.values: + content = '{} {} "{}" "{}" "{}" {}'.format(value.order, + value.preference, + value.flags, + value.service, + value.regexp, + value.replacement) + yield { + 'rdata': content, + 'name': record.name, + 'ttl': record.ttl, + 'type': record._type + } + + def _params_for_single(self, record): + yield { + 'rdata': record.value, + 'name': record.name, + 'ttl': record.ttl, + 'type': record._type + } + + _params_for_CNAME = _params_for_single + + def _params_for_MX(self, record): + for value in record.values: + yield { + 'rdata': value.exchange, + 'name': record.name, + 'prio': value.preference, + 'ttl': record.ttl, + 'type': record._type + } + + def _params_for_SRV(self, record): + for value in record.values: + yield { + 'rdata': "{} {} {} {}".format(value.priority, value.port, + value.weight, value.target), + 'name': record.name, + 'ttl': record.ttl, + 'type': record._type, + } + + def _params_for_TXT(self, record): + for value in record.values: + yield { + 'rdata': '"' + value.replace('\\;', ';') + '"', + 'name': record.name, + 'ttl': record.ttl, + 'type': record._type + } + + def _apply_Create(self, change): + new = change.new + params_for = getattr(self, '_params_for_{}'.format(new._type)) + for params in params_for(new): + self._client.record_create(new.zone.name[:-1], params) + + def _apply_Update(self, change): + self._apply_Delete(change) + self._apply_Create(change) + + def _apply_Delete(self, change): + existing = change.existing + zone = existing.zone + for record in self.zone_records(zone): + self.log.debug('apply_Delete: zone=%s, type=%s, host=%s', zone, + record['type'], record['host']) + if existing.name == record['host'] and \ + existing._type == record['type']: + self._client.record_delete(zone.name[:-1], record['id']) + + def _apply(self, plan): + desired = plan.desired + changes = plan.changes + self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, + len(changes)) + + domain_name = desired.name[:-1] + try: + self._client.domain(domain_name) + except EasyDNSClientNotFound: + self.log.debug('_apply: no matching zone, creating domain') + self._client.domain_create(domain_name) + + for change in changes: + class_name = change.__class__.__name__ + getattr(self, '_apply_{}'.format(class_name))(change) + + # Clear out the cache if any + self._zone_records.pop(desired.name, None) diff --git a/octodns/provider/gandi.py b/octodns/provider/gandi.py new file mode 100644 index 0000000..8401ea4 --- /dev/null +++ b/octodns/provider/gandi.py @@ -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) diff --git a/octodns/provider/ovh.py b/octodns/provider/ovh.py index 54f62ac..9f7cd9a 100644 --- a/octodns/provider/ovh.py +++ b/octodns/provider/ovh.py @@ -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): """ diff --git a/octodns/provider/powerdns.py b/octodns/provider/powerdns.py index bcb6980..de7743c 100644 --- a/octodns/provider/powerdns.py +++ b/octodns/provider/powerdns.py @@ -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 diff --git a/octodns/provider/ultra.py b/octodns/provider/ultra.py index eb10e0d..03b70de 100644 --- a/octodns/provider/ultra.py +++ b/octodns/provider/ultra.py @@ -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(): diff --git a/octodns/provider/yaml.py b/octodns/provider/yaml.py index 10add5a..55a1632 100644 --- a/octodns/provider/yaml.py +++ b/octodns/provider/yaml.py @@ -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): diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 849e035..f22eebf 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -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 diff --git a/octodns/source/axfr.py b/octodns/source/axfr.py index 70569d1..2e18ef0 100644 --- a/octodns/source/axfr.py +++ b/octodns/source/axfr.py @@ -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: diff --git a/requirements.txt b/requirements.txt index dd1643f..8b9c052 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/script/coverage b/script/coverage index 32bdaea..bd6e4c9 100755 --- a/script/coverage +++ b/script/coverage @@ -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 } diff --git a/setup.py b/setup.py index 9394e7f..b25909a 100644 --- a/setup.py +++ b/setup.py @@ -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__, ) diff --git a/tests/config/alias-zone-loop.yaml b/tests/config/alias-zone-loop.yaml new file mode 100644 index 0000000..df8b53f --- /dev/null +++ b/tests/config/alias-zone-loop.yaml @@ -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. diff --git a/tests/config/simple-alias-zone.yaml b/tests/config/simple-alias-zone.yaml new file mode 100644 index 0000000..32154d5 --- /dev/null +++ b/tests/config/simple-alias-zone.yaml @@ -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. + diff --git a/tests/config/split/unit.tests./dname.yaml b/tests/config/split/unit.tests./dname.yaml new file mode 100644 index 0000000..7cd1755 --- /dev/null +++ b/tests/config/split/unit.tests./dname.yaml @@ -0,0 +1,5 @@ +--- +dname: + ttl: 300 + type: DNAME + value: unit.tests. diff --git a/tests/config/unit.tests.yaml b/tests/config/unit.tests.yaml index 1da2465..7b84ac9 100644 --- a/tests/config/unit.tests.yaml +++ b/tests/config/unit.tests.yaml @@ -56,6 +56,10 @@ cname: ttl: 300 type: CNAME value: unit.tests. +dname: + ttl: 300 + type: DNAME + value: unit.tests. excluded: octodns: excluded: diff --git a/tests/config/unknown-source-zone.yaml b/tests/config/unknown-source-zone.yaml new file mode 100644 index 0000000..a3940ff --- /dev/null +++ b/tests/config/unknown-source-zone.yaml @@ -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. diff --git a/tests/fixtures/constellix-records.json b/tests/fixtures/constellix-records.json index c1f1fb4..689fd53 100644 --- a/tests/fixtures/constellix-records.json +++ b/tests/fixtures/constellix-records.json @@ -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", diff --git a/tests/fixtures/dnsmadeeasy-records.json b/tests/fixtures/dnsmadeeasy-records.json index 4d3ba64..aefd6ce 100644 --- a/tests/fixtures/dnsmadeeasy-records.json +++ b/tests/fixtures/dnsmadeeasy-records.json @@ -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, diff --git a/tests/fixtures/easydns-records.json b/tests/fixtures/easydns-records.json new file mode 100644 index 0000000..c3718b5 --- /dev/null +++ b/tests/fixtures/easydns-records.json @@ -0,0 +1,274 @@ +{ + "tm": 1000000000, + "data": [ + { + "id": "12340001", + "domain": "unit.tests", + "host": "@", + "ttl": "3600", + "prio": "0", + "type": "SOA", + "rdata": "dns1.easydns.com. zone.easydns.com. 2020010101 3600 600 604800 0", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340002", + "domain": "unit.tests", + "host": "@", + "ttl": "300", + "prio": "0", + "type": "A", + "rdata": "1.2.3.4", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340003", + "domain": "unit.tests", + "host": "@", + "ttl": "300", + "prio": "0", + "type": "A", + "rdata": "1.2.3.5", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340004", + "domain": "unit.tests", + "host": "@", + "ttl": "0", + "prio": null, + "type": "NS", + "rdata": "6.2.3.4.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340005", + "domain": "unit.tests", + "host": "@", + "ttl": "0", + "prio": null, + "type": "NS", + "rdata": "7.2.3.4.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340006", + "domain": "unit.tests", + "host": "@", + "ttl": "3600", + "prio": "0", + "type": "CAA", + "rdata": "0 issue ca.unit.tests", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340007", + "domain": "unit.tests", + "host": "_srv._tcp", + "ttl": "600", + "prio": "12", + "type": "SRV", + "rdata": "12 20 30 foo-2.unit.tests.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340008", + "domain": "unit.tests", + "host": "_srv._tcp", + "ttl": "600", + "prio": "12", + "type": "SRV", + "rdata": "10 20 30 foo-1.unit.tests.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340009", + "domain": "unit.tests", + "host": "aaaa", + "ttl": "600", + "prio": "0", + "type": "AAAA", + "rdata": "2601:644:500:e210:62f8:1dff:feb8:947a", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340010", + "domain": "unit.tests", + "host": "cname", + "ttl": "300", + "prio": null, + "type": "CNAME", + "rdata": "@", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340012", + "domain": "unit.tests", + "host": "mx", + "ttl": "300", + "prio": "10", + "type": "MX", + "rdata": "smtp-4.unit.tests.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340013", + "domain": "unit.tests", + "host": "mx", + "ttl": "300", + "prio": "20", + "type": "MX", + "rdata": "smtp-2.unit.tests.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340014", + "domain": "unit.tests", + "host": "mx", + "ttl": "300", + "prio": "30", + "type": "MX", + "rdata": "smtp-3.unit.tests.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340015", + "domain": "unit.tests", + "host": "mx", + "ttl": "300", + "prio": "40", + "type": "MX", + "rdata": "smtp-1.unit.tests.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340016", + "domain": "unit.tests", + "host": "naptr", + "ttl": "600", + "prio": null, + "type": "NAPTR", + "rdata": "100 100 'U' 'SIP+D2U' '!^.*$!sip:info@bar.example.com!' .", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340017", + "domain": "unit.tests", + "host": "naptr", + "ttl": "600", + "prio": null, + "type": "NAPTR", + "rdata": "10 100 'S' 'SIP+D2U' '!^.*$!sip:info@bar.example.com!' .", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340018", + "domain": "unit.tests", + "host": "sub", + "ttl": "3600", + "prio": null, + "type": "NS", + "rdata": "6.2.3.4.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340019", + "domain": "unit.tests", + "host": "sub", + "ttl": "0", + "prio": null, + "type": "NS", + "rdata": "7.2.3.4.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340020", + "domain": "unit.tests", + "host": "www", + "ttl": "300", + "prio": "0", + "type": "A", + "rdata": "2.2.3.6", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340021", + "domain": "unit.tests", + "host": "www.sub", + "ttl": "300", + "prio": "0", + "type": "A", + "rdata": "2.2.3.6", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340022", + "domain": "unit.tests", + "host": "included", + "ttl": "3600", + "prio": null, + "type": "CNAME", + "rdata": "unit.tests.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340011", + "domain": "unit.tests", + "host": "txt", + "ttl": "600", + "prio": "0", + "type": "TXT", + "rdata": "Bah bah black sheep", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340023", + "domain": "unit.tests", + "host": "txt", + "ttl": "600", + "prio": "0", + "type": "TXT", + "rdata": "have you any wool.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, + { + "id": "12340024", + "domain": "unit.tests", + "host": "txt", + "ttl": "600", + "prio": "0", + "type": "TXT", + "rdata": "v=DKIM1;k=rsa;s=email;h=sha256;p=A\/kinda+of\/long\/string+with+numb3rs", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + } + ], + "count": 24, + "total": 24, + "start": 0, + "max": 1000, + "status": 200 +} diff --git a/tests/fixtures/gandi-no-changes.json b/tests/fixtures/gandi-no-changes.json new file mode 100644 index 0000000..b018785 --- /dev/null +++ b/tests/fixtures/gandi-no-changes.json @@ -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." + ] + } + ] diff --git a/tests/fixtures/gandi-records.json b/tests/fixtures/gandi-records.json new file mode 100644 index 0000000..01d30f7 --- /dev/null +++ b/tests/fixtures/gandi-records.json @@ -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" + ] + } +] diff --git a/tests/fixtures/gandi-zone.json b/tests/fixtures/gandi-zone.json new file mode 100644 index 0000000..e132f4c --- /dev/null +++ b/tests/fixtures/gandi-zone.json @@ -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" +} \ No newline at end of file diff --git a/tests/fixtures/ultra-records-page-1.json b/tests/fixtures/ultra-records-page-1.json index 2f5f836..8614427 100644 --- a/tests/fixtures/ultra-records-page-1.json +++ b/tests/fixtures/ultra-records-page-1.json @@ -87,7 +87,7 @@ } ], "resultInfo": { - "totalCount": 12, + "totalCount": 13, "offset": 0, "returnedCount": 10 } diff --git a/tests/fixtures/ultra-records-page-2.json b/tests/fixtures/ultra-records-page-2.json index db51828..abdc44f 100644 --- a/tests/fixtures/ultra-records-page-2.json +++ b/tests/fixtures/ultra-records-page-2.json @@ -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 } } \ No newline at end of file diff --git a/tests/fixtures/ultra-zones-page-1.json b/tests/fixtures/ultra-zones-page-1.json index ad98d48..f748d08 100644 --- a/tests/fixtures/ultra-zones-page-1.json +++ b/tests/fixtures/ultra-zones-page-1.json @@ -19,7 +19,7 @@ "dnssecStatus": "UNSIGNED", "status": "ACTIVE", "owner": "phelpstest", - "resourceRecordCount": 5, + "resourceRecordCount": 6, "lastModifiedDateTime": "2020-06-19T01:05Z" } }, diff --git a/tests/test_octodns_manager.py b/tests/test_octodns_manager.py index 581689a..f757466 100644 --- a/tests/test_octodns_manager.py +++ b/tests/test_octodns_manager.py @@ -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 diff --git a/tests/test_octodns_provider_azuredns.py b/tests/test_octodns_provider_azuredns.py index 1769cef..3b008e5 100644 --- a/tests/test_octodns_provider_azuredns.py +++ b/tests/test_octodns_provider_azuredns.py @@ -389,9 +389,6 @@ class TestAzureDnsProvider(TestCase): recordSet = RecordSet(cname_record=cname1) recordSet.name, recordSet.ttl, recordSet.type = 'cname1', 5, 'CNAME' rs.append(recordSet) - recordSet = RecordSet(cname_record=None) - recordSet.name, recordSet.ttl, recordSet.type = 'cname2', 6, 'CNAME' - rs.append(recordSet) recordSet = RecordSet(mx_records=[MxRecord(preference=10, exchange='mx1.unit.test.')]) recordSet.name, recordSet.ttl, recordSet.type = 'mx1', 7, 'MX' @@ -413,9 +410,6 @@ class TestAzureDnsProvider(TestCase): recordSet = RecordSet(ptr_records=[ptr1]) recordSet.name, recordSet.ttl, recordSet.type = 'ptr1', 11, 'PTR' rs.append(recordSet) - recordSet = RecordSet(ptr_records=[PtrRecord(ptrdname=None)]) - recordSet.name, recordSet.ttl, recordSet.type = 'ptr2', 12, 'PTR' - rs.append(recordSet) recordSet = RecordSet(srv_records=[SrvRecord(priority=1, weight=2, port=3, @@ -449,7 +443,7 @@ class TestAzureDnsProvider(TestCase): exists = provider.populate(zone) self.assertTrue(exists) - self.assertEquals(len(zone.records), 18) + self.assertEquals(len(zone.records), 16) def test_populate_zone(self): provider = self._get_provider() diff --git a/tests/test_octodns_provider_constellix.py b/tests/test_octodns_provider_constellix.py index 151d0d4..bc17b50 100644 --- a/tests/test_octodns_provider_constellix.py +++ b/tests/test_octodns_provider_constellix.py @@ -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() diff --git a/tests/test_octodns_provider_digitalocean.py b/tests/test_octodns_provider_digitalocean.py index ebb5319..0ad8f72 100644 --- a/tests/test_octodns_provider_digitalocean.py +++ b/tests/test_octodns_provider_digitalocean.py @@ -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) diff --git a/tests/test_octodns_provider_dnsimple.py b/tests/test_octodns_provider_dnsimple.py index b918962..92f32b1 100644 --- a/tests/test_octodns_provider_dnsimple.py +++ b/tests/test_octodns_provider_dnsimple.py @@ -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) diff --git a/tests/test_octodns_provider_dnsmadeeasy.py b/tests/test_octodns_provider_dnsmadeeasy.py index ba61b94..0ad059d 100644 --- a/tests/test_octodns_provider_dnsmadeeasy.py +++ b/tests/test_octodns_provider_dnsmadeeasy.py @@ -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() diff --git a/tests/test_octodns_provider_easydns.py b/tests/test_octodns_provider_easydns.py new file mode 100644 index 0000000..8df0e22 --- /dev/null +++ b/tests/test_octodns_provider_easydns.py @@ -0,0 +1,448 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import json +from mock import Mock, call +from os.path import dirname, join +from requests import HTTPError +from requests_mock import ANY, mock as requests_mock +from six import text_type +from unittest import TestCase + +from octodns.record import Record +from octodns.provider.easydns import EasyDNSClientNotFound, \ + EasyDNSProvider +from octodns.provider.yaml import YamlProvider +from octodns.zone import Zone + + +class TestEasyDNSProvider(TestCase): + expected = Zone('unit.tests.', []) + source = YamlProvider('test', join(dirname(__file__), 'config')) + source.populate(expected) + + def test_populate(self): + provider = EasyDNSProvider('test', 'token', 'apikey') + + # Bad auth + with requests_mock() as mock: + mock.get(ANY, status_code=401, + text='{"id":"unauthorized",' + '"message":"Unable to authenticate you."}') + + with self.assertRaises(Exception) as ctx: + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals('Unauthorized', text_type(ctx.exception)) + + # Bad request + with requests_mock() as mock: + mock.get(ANY, status_code=400, + text='{"id":"invalid",' + '"message":"Bad request"}') + + with self.assertRaises(Exception) as ctx: + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals('Bad request', text_type(ctx.exception)) + + # General error + with requests_mock() as mock: + mock.get(ANY, status_code=502, text='Things caught fire') + + with self.assertRaises(HTTPError) as ctx: + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(502, ctx.exception.response.status_code) + + # Non-existent zone doesn't populate anything + with requests_mock() as mock: + mock.get(ANY, status_code=404, + text='{"id":"not_found","message":"The resource you ' + 'were accessing could not be found."}') + + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(set(), zone.records) + + # No diffs == no changes + with requests_mock() as mock: + base = 'https://rest.easydns.net/zones/records/' + with open('tests/fixtures/easydns-records.json') as fh: + mock.get('{}{}'.format(base, 'parsed/unit.tests'), + text=fh.read()) + with open('tests/fixtures/easydns-records.json') as fh: + mock.get('{}{}'.format(base, 'all/unit.tests'), + text=fh.read()) + + provider.populate(zone) + self.assertEquals(13, len(zone.records)) + changes = self.expected.changes(zone, provider) + self.assertEquals(0, len(changes)) + + # 2nd populate makes no network calls/all from cache + again = Zone('unit.tests.', []) + provider.populate(again) + self.assertEquals(13, len(again.records)) + + # bust the cache + del provider._zone_records[zone.name] + + def test_domain(self): + provider = EasyDNSProvider('test', 'token', 'apikey') + + with requests_mock() as mock: + base = 'https://rest.easydns.net/' + mock.get('{}{}'.format(base, 'domain/unit.tests'), status_code=400, + text='{"id":"not_found","message":"The resource you ' + 'were accessing could not be found."}') + + with self.assertRaises(Exception) as ctx: + provider._client.domain('unit.tests') + + self.assertEquals('Not Found', text_type(ctx.exception)) + + def test_apply_not_found(self): + provider = EasyDNSProvider('test', 'token', 'apikey') + + wanted = Zone('unit.tests.', []) + wanted.add_record(Record.new(wanted, 'test1', { + "name": "test1", + "ttl": 300, + "type": "A", + "value": "1.2.3.4", + })) + + with requests_mock() as mock: + base = 'https://rest.easydns.net/' + mock.get('{}{}'.format(base, 'domain/unit.tests'), status_code=404, + text='{"id":"not_found","message":"The resource you ' + 'were accessing could not be found."}') + mock.put('{}{}'.format(base, 'domains/add/unit.tests'), + status_code=200, + text='{"id":"OK","message":"Zone created."}') + mock.get('{}{}'.format(base, 'zones/records/parsed/unit.tests'), + status_code=404, + text='{"id":"not_found","message":"The resource you ' + 'were accessing could not be found."}') + mock.get('{}{}'.format(base, 'zones/records/all/unit.tests'), + status_code=404, + text='{"id":"not_found","message":"The resource you ' + 'were accessing could not be found."}') + + plan = provider.plan(wanted) + self.assertFalse(plan.exists) + self.assertEquals(1, len(plan.changes)) + with self.assertRaises(Exception) as ctx: + provider.apply(plan) + + self.assertEquals('Not Found', text_type(ctx.exception)) + + def test_domain_create(self): + provider = EasyDNSProvider('test', 'token', 'apikey') + domain_after_creation = { + "tm": 1000000000, + "data": [{ + "id": "12341001", + "domain": "unit.tests", + "host": "@", + "ttl": "0", + "prio": "0", + "type": "SOA", + "rdata": "dns1.easydns.com. zone.easydns.com. " + "2020010101 3600 600 604800 0", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, { + "id": "12341002", + "domain": "unit.tests", + "host": "@", + "ttl": "0", + "prio": "0", + "type": "NS", + "rdata": "LOCAL.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, { + "id": "12341003", + "domain": "unit.tests", + "host": "@", + "ttl": "0", + "prio": "0", + "type": "MX", + "rdata": "LOCAL.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }], + "count": 3, + "total": 3, + "start": 0, + "max": 1000, + "status": 200 + } + with requests_mock() as mock: + base = 'https://rest.easydns.net/' + mock.put('{}{}'.format(base, 'domains/add/unit.tests'), + status_code=201, text='{"id":"OK"}') + mock.get('{}{}'.format(base, 'zones/records/all/unit.tests'), + text=json.dumps(domain_after_creation)) + mock.delete(ANY, text='{"id":"OK"}') + provider._client.domain_create('unit.tests') + + def test_caa(self): + provider = EasyDNSProvider('test', 'token', 'apikey') + + # Invalid rdata records + caa_record_invalid = [{ + "domain": "unit.tests", + "host": "@", + "ttl": "3600", + "prio": "0", + "type": "CAA", + "rdata": "0", + }] + + # Valid rdata records + caa_record_valid = [{ + "domain": "unit.tests", + "host": "@", + "ttl": "3600", + "prio": "0", + "type": "CAA", + "rdata": "0 issue ca.unit.tests", + }] + + provider._data_for_CAA('CAA', caa_record_invalid) + provider._data_for_CAA('CAA', caa_record_valid) + + def test_naptr(self): + provider = EasyDNSProvider('test', 'token', 'apikey') + + # Invalid rdata records + naptr_record_invalid = [{ + "domain": "unit.tests", + "host": "naptr", + "ttl": "600", + "prio": "10", + "type": "NAPTR", + "rdata": "100", + }] + + # Valid rdata records + naptr_record_valid = [{ + "domain": "unit.tests", + "host": "naptr", + "ttl": "600", + "prio": "10", + "type": "NAPTR", + "rdata": "10 10 'U' 'SIP+D2U' '!^.*$!sip:info@bar.example.com!' .", + }] + + provider._data_for_NAPTR('NAPTR', naptr_record_invalid) + provider._data_for_NAPTR('NAPTR', naptr_record_valid) + + def test_srv(self): + provider = EasyDNSProvider('test', 'token', 'apikey') + + # Invalid rdata records + srv_invalid = [{ + "domain": "unit.tests", + "host": "_srv._tcp", + "ttl": "600", + "type": "SRV", + "rdata": "", + }] + srv_invalid2 = [{ + "domain": "unit.tests", + "host": "_srv._tcp", + "ttl": "600", + "type": "SRV", + "rdata": "11", + }] + srv_invalid3 = [{ + "domain": "unit.tests", + "host": "_srv._tcp", + "ttl": "600", + "type": "SRV", + "rdata": "12 30", + }] + srv_invalid4 = [{ + "domain": "unit.tests", + "host": "_srv._tcp", + "ttl": "600", + "type": "SRV", + "rdata": "13 40 1234", + }] + + # Valid rdata + srv_valid = [{ + "domain": "unit.tests", + "host": "_srv._tcp", + "ttl": "600", + "type": "SRV", + "rdata": "100 20 5678 foo-2.unit.tests.", + }] + + srv_invalid_content = provider._data_for_SRV('SRV', srv_invalid) + srv_invalid_content2 = provider._data_for_SRV('SRV', srv_invalid2) + srv_invalid_content3 = provider._data_for_SRV('SRV', srv_invalid3) + srv_invalid_content4 = provider._data_for_SRV('SRV', srv_invalid4) + srv_valid_content = provider._data_for_SRV('SRV', srv_valid) + + self.assertEqual(srv_valid_content['values'][0]['priority'], 100) + self.assertEqual(srv_invalid_content['values'][0]['priority'], 0) + self.assertEqual(srv_invalid_content2['values'][0]['priority'], 11) + self.assertEqual(srv_invalid_content3['values'][0]['priority'], 12) + self.assertEqual(srv_invalid_content4['values'][0]['priority'], 13) + + self.assertEqual(srv_valid_content['values'][0]['weight'], 20) + self.assertEqual(srv_invalid_content['values'][0]['weight'], 0) + self.assertEqual(srv_invalid_content2['values'][0]['weight'], 0) + self.assertEqual(srv_invalid_content3['values'][0]['weight'], 30) + self.assertEqual(srv_invalid_content4['values'][0]['weight'], 40) + + self.assertEqual(srv_valid_content['values'][0]['port'], 5678) + self.assertEqual(srv_invalid_content['values'][0]['port'], 0) + self.assertEqual(srv_invalid_content2['values'][0]['port'], 0) + self.assertEqual(srv_invalid_content3['values'][0]['port'], 0) + self.assertEqual(srv_invalid_content4['values'][0]['port'], 1234) + + self.assertEqual(srv_valid_content['values'][0]['target'], + 'foo-2.unit.tests.') + self.assertEqual(srv_invalid_content['values'][0]['target'], '') + self.assertEqual(srv_invalid_content2['values'][0]['target'], '') + self.assertEqual(srv_invalid_content3['values'][0]['target'], '') + self.assertEqual(srv_invalid_content4['values'][0]['target'], '') + + def test_apply(self): + provider = EasyDNSProvider('test', 'token', 'apikey') + + resp = Mock() + resp.json = Mock() + provider._client._request = Mock(return_value=resp) + + domain_after_creation = { + "tm": 1000000000, + "data": [{ + "id": "12341001", + "domain": "unit.tests", + "host": "@", + "ttl": "0", + "prio": "0", + "type": "SOA", + "rdata": "dns1.easydns.com. zone.easydns.com. 2020010101" + " 3600 600 604800 0", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, { + "id": "12341002", + "domain": "unit.tests", + "host": "@", + "ttl": "0", + "prio": "0", + "type": "NS", + "rdata": "LOCAL.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, { + "id": "12341003", + "domain": "unit.tests", + "host": "@", + "ttl": "0", + "prio": "0", + "type": "MX", + "rdata": "LOCAL.", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }], + "count": 3, + "total": 3, + "start": 0, + "max": 1000, + "status": 200 + } + + # non-existent domain, create everything + resp.json.side_effect = [ + EasyDNSClientNotFound, # no zone in populate + domain_after_creation + ] + plan = provider.plan(self.expected) + + # No root NS, no ignored, no excluded, no unsupported + n = len(self.expected.records) - 7 + self.assertEquals(n, len(plan.changes)) + self.assertEquals(n, provider.apply(plan)) + self.assertFalse(plan.exists) + + self.assertEquals(23, provider._client._request.call_count) + + provider._client._request.reset_mock() + + # delete 1 and update 1 + provider._client.records = Mock(return_value=[ + { + "id": "12342001", + "domain": "unit.tests", + "host": "www", + "ttl": "300", + "prio": "0", + "type": "A", + "rdata": "2.2.3.9", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, { + "id": "12342002", + "domain": "unit.tests", + "host": "www", + "ttl": "300", + "prio": "0", + "type": "A", + "rdata": "2.2.3.8", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + }, { + "id": "12342003", + "domain": "unit.tests", + "host": "test1", + "ttl": "3600", + "prio": "0", + "type": "A", + "rdata": "1.2.3.4", + "geozone_id": "0", + "last_mod": "2020-01-01 01:01:01" + } + ]) + + # Domain exists, we don't care about return + resp.json.side_effect = ['{}'] + + wanted = Zone('unit.tests.', []) + wanted.add_record(Record.new(wanted, 'test1', { + "name": "test1", + "ttl": 300, + "type": "A", + "value": "1.2.3.4", + })) + + plan = provider.plan(wanted) + self.assertTrue(plan.exists) + self.assertEquals(2, len(plan.changes)) + self.assertEquals(2, provider.apply(plan)) + # recreate for update, and delete for the 2 parts of the other + provider._client._request.assert_has_calls([ + call('PUT', '/zones/records/add/unit.tests/A', data={ + 'rdata': '1.2.3.4', + 'name': 'test1', + 'ttl': 300, + 'type': 'A', + 'host': 'test1', + }), + call('DELETE', '/zones/records/unit.tests/12342001'), + call('DELETE', '/zones/records/unit.tests/12342002'), + call('DELETE', '/zones/records/unit.tests/12342003') + ], any_order=True) diff --git a/tests/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py new file mode 100644 index 0000000..7e1c866 --- /dev/null +++ b/tests/test_octodns_provider_gandi.py @@ -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) diff --git a/tests/test_octodns_provider_mythicbeasts.py b/tests/test_octodns_provider_mythicbeasts.py index 960bd65..f78cb0b 100644 --- a/tests/test_octodns_provider_mythicbeasts.py +++ b/tests/test_octodns_provider_mythicbeasts.py @@ -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', diff --git a/tests/test_octodns_provider_powerdns.py b/tests/test_octodns_provider_powerdns.py index fd877ef..33b5e44 100644 --- a/tests/test_octodns_provider_powerdns.py +++ b/tests/test_octodns_provider_powerdns.py @@ -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: diff --git a/tests/test_octodns_provider_yaml.py b/tests/test_octodns_provider_yaml.py index f858c05..15e90da 100644 --- a/tests/test_octodns_provider_yaml.py +++ b/tests/test_octodns_provider_yaml.py @@ -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) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 08a3e7a..d55b3b8 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -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, '', { diff --git a/tests/test_octodns_source_axfr.py b/tests/test_octodns_source_axfr.py index bd25062..1bf3f22 100644 --- a/tests/test_octodns_source_axfr.py +++ b/tests/test_octodns_source_axfr.py @@ -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] diff --git a/tests/zones/unit.tests. b/tests/zones/unit.tests. index 0305e05..838de88 100644 --- a/tests/zones/unit.tests. +++ b/tests/zones/unit.tests. @@ -13,6 +13,10 @@ $ORIGIN unit.tests. under 3600 IN NS ns1.unit.tests. under 3600 IN NS ns2.unit.tests. +; CAA Records +caa 1800 IN CAA 0 issue "ca.unit.tests" +caa 1800 IN CAA 0 iodef "mailto:admin@unit.tests" + ; SRV Records _srv._tcp 600 IN SRV 10 20 30 foo-1.unit.tests. _srv._tcp 600 IN SRV 10 20 30 foo-2.unit.tests.