From 142295a16c8bebff36037d9902c21c4adcbc560e Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Tue, 11 Jan 2022 12:24:34 -0800 Subject: [PATCH] Extract GCoreProvider from octoDNS core --- CHANGELOG.md | 1 + README.md | 2 +- octodns/provider/gcore.py | 629 +------------------------ tests/fixtures/gcore-no-changes.json | 245 ---------- tests/fixtures/gcore-records.json | 428 ----------------- tests/fixtures/gcore-zone.json | 27 -- tests/test_octodns_provider_gcore.py | 669 +-------------------------- 7 files changed, 26 insertions(+), 1975 deletions(-) delete mode 100644 tests/fixtures/gcore-no-changes.json delete mode 100644 tests/fixtures/gcore-records.json delete mode 100644 tests/fixtures/gcore-zone.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e1850..829887c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * [DynProvider](https://github.com/octodns/octodns-dynprovider/) * [EasyDnsProvider](https://github.com/octodns/octodns-easydns/) * [EtcHostsProvider](https://github.com/octodns/octodns-etchosts/) + * [GcoreProvider](https://github.com/octodns/octodns-gcore/) * [Ns1Provider](https://github.com/octodns/octodns-ns1/) * [PowerDnsProvider](https://github.com/octodns/octodns-powerdns/) * [Route53Provider](https://github.com/octodns/octodns-route53/) also diff --git a/README.md b/README.md index a7aeaad..a469493 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ The table below lists the providers octoDNS supports. We're currently in the pro | [EtcHostsProvider](https://github.com/octodns/octodns-etchosts/) | [octodns_etchosts](https://github.com/octodns/octodns-etchosts/) | | | | | | [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 | | -| [GCoreProvider](/octodns/provider/gcore.py) | | | A, AAAA, NS, MX, TXT, SRV, CNAME, PTR | Dynamic | | +| [GCoreProvider](https://github.com/octodns/octodns-gcore/) | [octodns_gcore](https://github.com/octodns/octodns-gcore/) | | | | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [HetznerProvider](/octodns/provider/hetzner.py) | | | A, AAAA, CAA, CNAME, MX, NS, SRV, TXT | No | | | [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | | diff --git a/octodns/provider/gcore.py b/octodns/provider/gcore.py index 10fd612..a8e1c69 100644 --- a/octodns/provider/gcore.py +++ b/octodns/provider/gcore.py @@ -2,615 +2,20 @@ # # -from __future__ import ( - absolute_import, - division, - print_function, - unicode_literals, -) - -from collections import defaultdict -from requests import Session -import http -import logging -import urllib.parse - -from ..record import GeoCodes -from ..record import Record -from . import ProviderException -from .base import BaseProvider - - -class GCoreClientException(ProviderException): - def __init__(self, r): - super(GCoreClientException, self).__init__(r.text) - - -class GCoreClientBadRequest(GCoreClientException): - def __init__(self, r): - super(GCoreClientBadRequest, self).__init__(r) - - -class GCoreClientNotFound(GCoreClientException): - def __init__(self, r): - super(GCoreClientNotFound, self).__init__(r) - - -class GCoreClient(object): - - ROOT_ZONES = "zones" - - def __init__( - self, - log, - api_url, - auth_url, - token=None, - token_type=None, - login=None, - password=None, - ): - self.log = log - self._session = Session() - self._api_url = api_url - if token is not None and token_type is not None: - self._session.headers.update( - {"Authorization": f"{token_type} {token}"} - ) - elif login is not None and password is not None: - token = self._auth(auth_url, login, password) - self._session.headers.update( - {"Authorization": f"Bearer {token}"} - ) - else: - raise ValueError("either token or login & password must be set") - - def _auth(self, url, login, password): - # well, can't use _request, since API returns 400 if credentials - # invalid which will be logged, but we don't want do this - r = self._session.request( - "POST", - self._build_url(url, "auth", "jwt", "login"), - json={"username": login, "password": password}, - ) - r.raise_for_status() - return r.json()["access"] - - def _request(self, method, url, params=None, data=None): - r = self._session.request( - method, url, params=params, json=data, timeout=30.0 - ) - if r.status_code == http.HTTPStatus.BAD_REQUEST: - self.log.error( - "bad request %r has been sent to %r: %s", data, url, r.text - ) - raise GCoreClientBadRequest(r) - elif r.status_code == http.HTTPStatus.NOT_FOUND: - self.log.error("resource %r not found: %s", url, r.text) - raise GCoreClientNotFound(r) - elif r.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR: - self.log.error("server error no %r to %r: %s", data, url, r.text) - raise GCoreClientException(r) - r.raise_for_status() - return r - - def zone(self, zone_name): - return self._request( - "GET", self._build_url(self._api_url, self.ROOT_ZONES, zone_name) - ).json() - - def zone_create(self, zone_name): - return self._request( - "POST", - self._build_url(self._api_url, self.ROOT_ZONES), - data={"name": zone_name}, - ).json() - - def zone_records(self, zone_name): - url = self._build_url(self._api_url, self.ROOT_ZONES, zone_name, - "rrsets") - rrsets = self._request("GET", url, params={"all": "true"}).json() - records = rrsets["rrsets"] - return records - - def record_create(self, zone_name, rrset_name, type_, data): - self._request( - "POST", self._rrset_url(zone_name, rrset_name, type_), data=data - ) - - def record_update(self, zone_name, rrset_name, type_, data): - self._request( - "PUT", self._rrset_url(zone_name, rrset_name, type_), data=data - ) - - def record_delete(self, zone_name, rrset_name, type_): - self._request("DELETE", self._rrset_url(zone_name, rrset_name, type_)) - - def _rrset_url(self, zone_name, rrset_name, type_): - return self._build_url( - self._api_url, self.ROOT_ZONES, zone_name, rrset_name, type_ - ) - - @staticmethod - def _build_url(base, *items): - for i in items: - base = base.strip("/") + "/" - base = urllib.parse.urljoin(base, i) - return base - - -class GCoreProvider(BaseProvider): - """ - GCore provider using API v2. - - gcore: - class: octodns.provider.gcore.GCoreProvider - # Your API key - token: XXXXXXXXXXXX - # token_type: APIKey - # or login + password - login: XXXXXXXXXXXX - password: XXXXXXXXXXXX - # auth_url: https://api.gcdn.co - # url: https://dnsapi.gcorelabs.com/v2 - # records_per_response: 1 - """ - - SUPPORTS_GEO = False - SUPPORTS_DYNAMIC = True - SUPPORTS = set(("A", "AAAA", "NS", "MX", "TXT", "SRV", "CNAME", "PTR")) - - def __init__(self, id, *args, **kwargs): - token = kwargs.pop("token", None) - token_type = kwargs.pop("token_type", "APIKey") - login = kwargs.pop("login", None) - password = kwargs.pop("password", None) - api_url = kwargs.pop("url", "https://dnsapi.gcorelabs.com/v2") - auth_url = kwargs.pop("auth_url", "https://api.gcdn.co") - self.records_per_response = kwargs.pop("records_per_response", 1) - self.log = logging.getLogger(f"GCoreProvider[{id}]") - self.log.debug("__init__: id=%s", id) - super(GCoreProvider, self).__init__(id, *args, **kwargs) - self._client = GCoreClient( - self.log, - api_url, - auth_url, - token=token, - token_type=token_type, - login=login, - password=password, - ) - - def _add_dot_if_need(self, value): - return f"{value}." if not value.endswith(".") else value - - def _build_pools(self, record, default_pool_name, value_transform_fn): - defaults = [] - geo_sets, pool_idx = dict(), 0 - pools = defaultdict(lambda: {"values": []}) - for rr in record["resource_records"]: - meta = rr.get("meta", {}) or {} - value = {"value": value_transform_fn(rr["content"][0])} - countries = meta.get("countries", []) or [] - continents = meta.get("continents", []) or [] - - if meta.get("default", False): - pools[default_pool_name]["values"].append(value) - defaults.append(value["value"]) - continue - # defaults is false or missing and no conties or continents - elif len(continents) == 0 and len(countries) == 0: - defaults.append(value["value"]) - continue - - # RR with the same set of countries and continents are - # combined in single pool - geo_set = frozenset( - [GeoCodes.country_to_code(cc.upper()) for cc in countries] - ) | frozenset(cc.upper() for cc in continents) - if geo_set not in geo_sets: - geo_sets[geo_set] = f"pool-{pool_idx}" - pool_idx += 1 - - pools[geo_sets[geo_set]]["values"].append(value) - - return pools, geo_sets, defaults - - def _build_rules(self, pools, geo_sets): - rules = [] - for name, _ in pools.items(): - rule = {"pool": name} - geo_set = next( - ( - geo_set - for geo_set, pool_name in geo_sets.items() - if pool_name == name - ), - {}, - ) - if len(geo_set) > 0: - rule["geos"] = list(geo_set) - rules.append(rule) - - return sorted(rules, key=lambda x: x["pool"]) - - def _data_for_dynamic(self, record, value_transform_fn=lambda x: x): - default_pool = "other" - pools, geo_sets, defaults = self._build_pools( - record, default_pool, value_transform_fn - ) - if len(pools) == 0: - raise RuntimeError( - f"filter is enabled, but no pools where built for {record}" - ) - - # defaults can't be empty, so use first pool values - if len(defaults) == 0: - defaults = [ - value_transform_fn(v["value"]) - for v in next(iter(pools.values()))["values"] - ] - - # if at least one default RR was found then setup fallback for - # other pools to default - if default_pool in pools: - for pool_name, pool in pools.items(): - if pool_name == default_pool: - continue - pool["fallback"] = default_pool - - rules = self._build_rules(pools, geo_sets) - return pools, rules, defaults - - def _data_for_single(self, _type, record): - return { - "ttl": record["ttl"], - "type": _type, - "value": self._add_dot_if_need( - record["resource_records"][0]["content"][0] - ), - } - - _data_for_PTR = _data_for_single - - def _data_for_CNAME(self, _type, record): - if record.get("filters") is None: - return self._data_for_single(_type, record) - - pools, rules, defaults = self._data_for_dynamic( - record, self._add_dot_if_need - ) - return { - "ttl": record["ttl"], - "type": _type, - "dynamic": {"pools": pools, "rules": rules}, - "value": self._add_dot_if_need(defaults[0]), - } - - def _data_for_multiple(self, _type, record): - extra = dict() - if record.get("filters") is not None: - pools, rules, defaults = self._data_for_dynamic(record) - extra = { - "dynamic": {"pools": pools, "rules": rules}, - "values": defaults, - } - else: - extra = { - "values": [ - rr_value - for resource_record in record["resource_records"] - for rr_value in resource_record["content"] - ] - } - return { - "ttl": record["ttl"], - "type": _type, - **extra, - } - - _data_for_A = _data_for_multiple - _data_for_AAAA = _data_for_multiple - - def _data_for_TXT(self, _type, record): - return { - "ttl": record["ttl"], - "type": _type, - "values": [ - rr_value.replace(";", "\\;") - for resource_record in record["resource_records"] - for rr_value in resource_record["content"] - ], - } - - def _data_for_MX(self, _type, record): - return { - "ttl": record["ttl"], - "type": _type, - "values": [ - dict( - preference=preference, - exchange=self._add_dot_if_need(exchange), - ) - for preference, exchange in map( - lambda x: x["content"], record["resource_records"] - ) - ], - } - - def _data_for_NS(self, _type, record): - return { - "ttl": record["ttl"], - "type": _type, - "values": [ - self._add_dot_if_need(rr_value) - for resource_record in record["resource_records"] - for rr_value in resource_record["content"] - ], - } - - def _data_for_SRV(self, _type, record): - return { - "ttl": record["ttl"], - "type": _type, - "values": [ - dict( - priority=priority, - weight=weight, - port=port, - target=self._add_dot_if_need(target), - ) - for priority, weight, port, target in map( - lambda x: x["content"], record["resource_records"] - ) - ], - } - - def zone_records(self, zone): - try: - return self._client.zone_records(zone.name[:-1]), True - except GCoreClientNotFound: - return [], False - - def populate(self, zone, target=False, lenient=False): - self.log.debug( - "populate: name=%s, target=%s, lenient=%s", - zone.name, - target, - lenient, - ) - - values = defaultdict(defaultdict) - records, exists = self.zone_records(zone) - for record in records: - _type = record["type"].upper() - if _type not in self.SUPPORTS: - continue - if self._should_ignore(record): - continue - rr_name = zone.hostname_from_fqdn(record["name"]) - values[rr_name][_type] = record - - before = len(zone.records) - for name, types in values.items(): - for _type, record in types.items(): - data_for = getattr(self, f"_data_for_{_type}") - record = Record.new( - zone, - name, - data_for(_type, record), - source=self, - lenient=lenient, - ) - zone.add_record(record, lenient=lenient) - - self.log.info( - "populate: found %s records, exists=%s", - len(zone.records) - before, - exists, - ) - return exists - - def _should_ignore(self, record): - name = record.get("name", "name-not-defined") - if record.get("filters") is None: - return False - want_filters = 3 - filters = record.get("filters", []) - if len(filters) != want_filters: - self.log.info( - "ignore %s has filters and their count is not %d", - name, - want_filters, - ) - return True - types = [v.get("type") for v in filters] - for i, want_type in enumerate(["geodns", "default", "first_n"]): - if types[i] != want_type: - self.log.info( - "ignore %s, filters.%d.type is %s, want %s", - name, - i, - types[i], - want_type, - ) - return True - limits = [filters[i].get("limit", 1) for i in [1, 2]] - if limits[0] != limits[1]: - self.log.info( - "ignore %s, filters.1.limit (%d) != filters.2.limit (%d)", - name, - limits[0], - limits[1], - ) - return True - return False - - def _params_for_dymanic(self, record): - records = [] - default_pool_found = False - default_values = set( - record.values if hasattr(record, "values") else [record.value] - ) - for rule in record.dynamic.rules: - meta = dict() - # build meta tags if geos information present - if len(rule.data.get("geos", [])) > 0: - for geo_code in rule.data["geos"]: - geo = GeoCodes.parse(geo_code) - - country = geo["country_code"] - continent = geo["continent_code"] - if country is not None: - meta.setdefault("countries", []).append(country) - else: - meta.setdefault("continents", []).append(continent) - else: - meta["default"] = True - - pool_values = set() - pool_name = rule.data["pool"] - for value in record.dynamic.pools[pool_name].data["values"]: - v = value["value"] - records.append({"content": [v], "meta": meta}) - pool_values.add(v) - - default_pool_found |= default_values == pool_values - - # if default values doesn't match any pool values, then just add this - # values with no any meta - if not default_pool_found: - for value in default_values: - records.append({"content": [value]}) - - return records - - def _params_for_single(self, record): - return { - "ttl": record.ttl, - "resource_records": [{"content": [record.value]}], - } - - _params_for_PTR = _params_for_single - - def _params_for_CNAME(self, record): - if not record.dynamic: - return self._params_for_single(record) - - return { - "ttl": record.ttl, - "resource_records": self._params_for_dymanic(record), - "filters": [ - {"type": "geodns"}, - { - "type": "default", - "limit": self.records_per_response, - "strict": False, - }, - {"type": "first_n", "limit": self.records_per_response}, - ], - } - - def _params_for_multiple(self, record): - extra = dict() - if record.dynamic: - extra["resource_records"] = self._params_for_dymanic(record) - extra["filters"] = [ - {"type": "geodns"}, - { - "type": "default", - "limit": self.records_per_response, - "strict": False, - }, - {"type": "first_n", "limit": self.records_per_response}, - ] - else: - extra["resource_records"] = [ - {"content": [value]} for value in record.values - ] - return { - "ttl": record.ttl, - **extra, - } - - _params_for_A = _params_for_multiple - _params_for_AAAA = _params_for_multiple - - def _params_for_NS(self, record): - return { - "ttl": record.ttl, - "resource_records": [ - {"content": [value]} for value in record.values - ], - } - - def _params_for_TXT(self, record): - return { - "ttl": record.ttl, - "resource_records": [ - {"content": [value.replace("\\;", ";")]} - for value in record.values - ], - } - - def _params_for_MX(self, record): - return { - "ttl": record.ttl, - "resource_records": [ - {"content": [rec.preference, rec.exchange]} - for rec in record.values - ], - } - - def _params_for_SRV(self, record): - return { - "ttl": record.ttl, - "resource_records": [ - {"content": [rec.priority, rec.weight, rec.port, rec.target]} - for rec in record.values - ], - } - - def _apply_create(self, change): - self.log.info("creating: %s", change) - new = change.new - data = getattr(self, f"_params_for_{new._type}")(new) - self._client.record_create( - new.zone.name[:-1], new.fqdn, new._type, data - ) - - def _apply_update(self, change): - self.log.info("updating: %s", change) - new = change.new - data = getattr(self, f"_params_for_{new._type}")(new) - self._client.record_update( - new.zone.name[:-1], new.fqdn, new._type, data - ) - - def _apply_delete(self, change): - self.log.info("deleting: %s", change) - existing = change.existing - self._client.record_delete( - existing.zone.name[:-1], existing.fqdn, 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 GCoreClientNotFound: - self.log.info("_apply: no existing zone, trying to create it") - self._client.zone_create(zone) - self.log.info("_apply: zone has been successfully created") - - changes.reverse() - - for change in changes: - class_name = change.__class__.__name__ - getattr(self, f"_apply_{class_name.lower()}")(change) +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from logging import getLogger + +logger = getLogger('GCore') +try: + logger.warn('octodns_gcore shimmed. Update your provider class to ' + 'octodns_gcore.GCoreProvider. ' + 'Shim will be removed in 1.0') + from octodns_gcore import GCoreProvider + GCoreProvider # pragma: no cover +except ModuleNotFoundError: + logger.exception('GCoreProvider has been moved into a seperate module, ' + 'octodns_gcore is now required. Provider class should ' + 'be updated to octodns_gcore.GCoreProvider') + raise diff --git a/tests/fixtures/gcore-no-changes.json b/tests/fixtures/gcore-no-changes.json deleted file mode 100644 index b1a3b25..0000000 --- a/tests/fixtures/gcore-no-changes.json +++ /dev/null @@ -1,245 +0,0 @@ -{ - "rrsets": [ - { - "name": "unit.tests", - "type": "A", - "ttl": 300, - "resource_records": [ - { - "content": [ - "1.2.3.4" - ] - }, - { - "content": [ - "1.2.3.5" - ] - } - ] - }, - { - "name": "unit.tests", - "type": "NS", - "ttl": 300, - "resource_records": [ - { - "content": [ - "ns2.gcdn.services" - ] - }, - { - "content": [ - "ns1.gcorelabs.net" - ] - } - ] - }, - { - "name": "_imap._tcp", - "type": "SRV", - "ttl": 600, - "resource_records": [ - { - "content": [ - 0, - 0, - 0, - "." - ] - } - ] - }, - { - "name": "_pop3._tcp", - "type": "SRV", - "ttl": 600, - "resource_records": [ - { - "content": [ - 0, - 0, - 0, - "." - ] - } - ] - }, - { - "name": "_srv._tcp", - "type": "SRV", - "ttl": 600, - "resource_records": [ - { - "content": [ - 12, - 20, - 30, - "foo-2.unit.tests" - ] - }, - { - "content": [ - 10, - 20, - 30, - "foo-1.unit.tests" - ] - } - ] - }, - { - "name": "aaaa.unit.tests", - "type": "AAAA", - "ttl": 600, - "resource_records": [ - { - "content": [ - "2601:644:500:e210:62f8:1dff:feb8:947a" - ] - } - ] - }, - { - "name": "cname.unit.tests", - "type": "CNAME", - "ttl": 300, - "resource_records": [ - { - "content": [ - "unit.tests." - ] - } - ] - }, - { - "name": "excluded.unit.tests", - "type": "CNAME", - "ttl": 3600, - "resource_records": [ - { - "content": [ - "unit.tests." - ] - } - ] - }, - { - "name": "mx.unit.tests", - "type": "MX", - "ttl": 300, - "resource_records": [ - { - "content": [ - 40, - "smtp-1.unit.tests." - ] - }, - { - "content": [ - 20, - "smtp-2.unit.tests." - ] - }, - { - "content": [ - 30, - "smtp-3.unit.tests." - ] - }, - { - "content": [ - 10, - "smtp-4.unit.tests." - ] - } - ] - }, - { - "name": "ptr.unit.tests.", - "type": "PTR", - "ttl": 300, - "resource_records": [ - { - "content": [ - "foo.bar.com" - ] - } - ] - }, - { - "name": "sub.unit.tests", - "type": "NS", - "ttl": 3600, - "resource_records": [ - { - "content": [ - "6.2.3.4" - ] - }, - { - "content": [ - "7.2.3.4" - ] - } - ] - }, - { - "name": "txt.unit.tests", - "type": "TXT", - "ttl": 600, - "resource_records": [ - { - "content": [ - "Bah bah black sheep" - ] - }, - { - "content": [ - "have you any wool." - ] - }, - { - "content": [ - "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs" - ] - } - ] - }, - { - "name": "www.unit.tests.", - "type": "A", - "ttl": 300, - "resource_records": [ - { - "content": [ - "2.2.3.6" - ] - } - ] - }, - { - "name": "www.sub.unit.tests.", - "type": "A", - "ttl": 300, - "resource_records": [ - { - "content": [ - "2.2.3.6" - ] - } - ] - }, - { - "name": "spf.sub.unit.tests.", - "type": "SPF", - "ttl": 600, - "resource_records": [ - { - "content": [ - "v=spf1 ip4:192.168.0.1/16-all" - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/tests/fixtures/gcore-records.json b/tests/fixtures/gcore-records.json deleted file mode 100644 index 9bf58d7..0000000 --- a/tests/fixtures/gcore-records.json +++ /dev/null @@ -1,428 +0,0 @@ -{ - "rrsets": [ - { - "name": "unit.tests", - "type": "A", - "ttl": 300, - "resource_records": [ - { - "content": [ - "1.2.3.4" - ] - } - ] - }, - { - "name": "unit.tests", - "type": "NS", - "ttl": 300, - "resource_records": [ - { - "content": [ - "ns2.gcdn.services" - ] - }, - { - "content": [ - "ns1.gcorelabs.net" - ] - } - ] - }, - { - "name": "_imap._tcp", - "type": "SRV", - "ttl": 1200, - "resource_records": [ - { - "content": [ - 0, - 0, - 0, - "." - ] - } - ] - }, - { - "name": "_pop3._tcp", - "type": "SRV", - "ttl": 1200, - "resource_records": [ - { - "content": [ - 0, - 0, - 0, - "." - ] - } - ] - }, - { - "name": "_srv._tcp", - "type": "SRV", - "ttl": 1200, - "resource_records": [ - { - "content": [ - 12, - 20, - 30, - "foo-2.unit.tests." - ] - }, - { - "content": [ - 10, - 20, - 30, - "foo-1.unit.tests." - ] - } - ] - }, - { - "name": "aaaa.unit.tests", - "type": "AAAA", - "ttl": 600, - "resource_records": [ - { - "content": [ - "2601:644:500:e210:62f8:1dff:feb8:947a" - ] - } - ] - }, - { - "name": "cname.unit.tests", - "type": "CNAME", - "ttl": 300, - "resource_records": [ - { - "content": [ - "unit.tests." - ] - } - ] - }, - { - "name": "mx.unit.tests", - "type": "MX", - "ttl": 600, - "resource_records": [ - { - "content": [ - 40, - "smtp-1.unit.tests." - ] - }, - { - "content": [ - 20, - "smtp-2.unit.tests." - ] - } - ] - }, - { - "name": "ptr.unit.tests.", - "type": "PTR", - "ttl": 300, - "resource_records": [ - { - "content": [ - "foo.bar.com" - ] - } - ] - }, - { - "name": "sub.unit.tests", - "type": "NS", - "ttl": 300, - "resource_records": [ - { - "content": [ - "6.2.3.4" - ] - }, - { - "content": [ - "7.2.3.4" - ] - } - ] - }, - { - "name": "txt.unit.tests", - "type": "TXT", - "ttl": 300, - "resource_records": [ - { - "content": [ - "\"Bah bah black sheep\"" - ] - }, - { - "content": [ - "\"have you any wool.\"" - ] - }, - { - "content": [ - "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"" - ] - } - ] - }, - { - "name": "www.unit.tests.", - "type": "A", - "ttl": 300, - "resource_records": [ - { - "content": [ - "2.2.3.6" - ] - } - ] - }, - { - "name": "www.sub.unit.tests.", - "type": "A", - "ttl": 300, - "resource_records": [ - { - "content": [ - "2.2.3.6" - ] - } - ] - }, - { - "name": "geo-A-single.unit.tests.", - "type": "A", - "ttl": 300, - "filters": [ - { - "type": "geodns" - }, - { - "limit": 1, - "strict": false, - "type": "default" - }, - { - "limit": 1, - "type": "first_n" - } - ], - "resource_records": [ - { - "content": [ - "7.7.7.7" - ], - "meta": { - "countries": [ - "RU" - ] - } - }, - { - "content": [ - "8.8.8.8" - ], - "meta": { - "countries": [ - "RU" - ] - } - }, - { - "content": [ - "9.9.9.9" - ], - "meta": { - "continents": [ - "EU" - ] - } - }, - { - "content": [ - "10.10.10.10" - ], - "meta": { - "default": true - } - } - ] - }, - { - "name": "geo-no-def.unit.tests.", - "type": "A", - "ttl": 300, - "filters": [ - { - "type": "geodns" - }, - { - "limit": 1, - "strict": false, - "type": "default" - }, - { - "limit": 1, - "type": "first_n" - } - ], - "resource_records": [ - { - "content": [ - "7.7.7.7" - ], - "meta": { - "countries": [ - "RU" - ] - } - } - ] - }, - { - "name": "geo-CNAME.unit.tests.", - "type": "CNAME", - "ttl": 300, - "filters": [ - { - "type": "geodns" - }, - { - "limit": 1, - "strict": false, - "type": "default" - }, - { - "limit": 1, - "type": "first_n" - } - ], - "resource_records": [ - { - "content": [ - "ru-1.unit.tests" - ], - "meta": { - "countries": [ - "RU" - ] - } - }, - { - "content": [ - "ru-2.unit.tests" - ], - "meta": { - "countries": [ - "RU" - ] - } - }, - { - "content": [ - "eu.unit.tests" - ], - "meta": { - "continents": [ - "EU" - ] - } - }, - { - "content": [ - "any.unit.tests." - ], - "meta": { - "default": true - } - } - ] - }, - { - "name": "geo-ignore-len-filters.unit.tests.", - "type": "A", - "ttl": 300, - "filters": [ - { - "limit": 1, - "type": "first_n" - }, - { - "limit": 1, - "strict": false, - "type": "default" - } - ], - "resource_records": [ - { - "content": [ - "7.7.7.7" - ] - } - ] - }, - { - "name": "geo-ignore-types.unit.tests.", - "type": "A", - "ttl": 300, - "filters": [ - { - "type": "geodistance" - }, - { - "limit": 1, - "type": "first_n" - }, - { - "limit": 1, - "strict": false, - "type": "default" - } - ], - "resource_records": [ - { - "content": [ - "7.7.7.7" - ] - } - ] - }, - { - "name": "geo-ignore-limits.unit.tests.", - "type": "A", - "ttl": 300, - "filters": [ - { - "type": "geodns" - }, - { - "limit": 2, - "strict": false, - "type": "default" - }, - { - "limit": 1, - "type": "first_n" - } - ], - "resource_records": [ - { - "content": [ - "7.7.7.7" - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/tests/fixtures/gcore-zone.json b/tests/fixtures/gcore-zone.json deleted file mode 100644 index 925af72..0000000 --- a/tests/fixtures/gcore-zone.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "id": 27757, - "name": "unit.test", - "nx_ttl": 300, - "retry": 5400, - "refresh": 0, - "expiry": 1209600, - "contact": "support@gcorelabs.com", - "serial": 1614752868, - "primary_server": "ns1.gcorelabs.net", - "records": [ - { - "id": 12419, - "name": "unit.test", - "type": "ns", - "ttl": 300, - "short_answers": [ - "[ns2.gcdn.services]", - "[ns1.gcorelabs.net]" - ] - } - ], - "dns_servers": [ - "ns1.gcorelabs.net", - "ns2.gcdn.services" - ] -} \ No newline at end of file diff --git a/tests/test_octodns_provider_gcore.py b/tests/test_octodns_provider_gcore.py index 1e706cb..0f40c8b 100644 --- a/tests/test_octodns_provider_gcore.py +++ b/tests/test_octodns_provider_gcore.py @@ -2,670 +2,15 @@ # # -from __future__ import ( - absolute_import, - division, - print_function, - unicode_literals, -) +from __future__ import absolute_import, division, print_function, \ + unicode_literals -from mock import Mock, call -from os.path import dirname, join -from requests_mock import ANY, mock as requests_mock from unittest import TestCase -from octodns.record import Record, Update, Delete, Create -from octodns.provider.gcore import ( - GCoreProvider, - GCoreClientBadRequest, - GCoreClientNotFound, - GCoreClientException, -) -from octodns.provider.yaml import YamlProvider -from octodns.zone import Zone +class TestGCoreShim(TestCase): -class TestGCoreProvider(TestCase): - expected = Zone("unit.tests.", []) - source = YamlProvider("test", join(dirname(__file__), "config")) - source.populate(expected) - - default_filters = [ - {"type": "geodns"}, - { - "type": "default", - "limit": 1, - "strict": False, - }, - {"type": "first_n", "limit": 1}, - ] - - def test_populate(self): - - provider = GCoreProvider("test_id", token="token") - - # TC: 400 - Bad Request. - with requests_mock() as mock: - mock.get(ANY, status_code=400, text='{"error":"bad body"}') - - with self.assertRaises(GCoreClientBadRequest) as ctx: - zone = Zone("unit.tests.", []) - provider.populate(zone) - self.assertIn('"error":"bad body"', str(ctx.exception)) - - # TC: 404 - Not Found. - with requests_mock() as mock: - mock.get( - ANY, status_code=404, text='{"error":"zone is not found"}' - ) - - with self.assertRaises(GCoreClientNotFound) as ctx: - zone = Zone("unit.tests.", []) - provider._client.zone(zone.name) - self.assertIn( - '"error":"zone is not found"', str(ctx.exception) - ) - - # TC: General error - with requests_mock() as mock: - mock.get(ANY, status_code=500, text="Things caught fire") - - with self.assertRaises(GCoreClientException) as ctx: - zone = Zone("unit.tests.", []) - provider.populate(zone) - self.assertEqual("Things caught fire", str(ctx.exception)) - - # TC: No credentials or token error - with requests_mock() as mock: - with self.assertRaises(ValueError) as ctx: - GCoreProvider("test_id") - self.assertEqual( - "either token or login & password must be set", - str(ctx.exception), - ) - - # TC: Auth with login password - with requests_mock() as mock: - - def match_body(request): - return {"username": "foo", "password": "bar"} == request.json() - - auth_url = "http://api/auth/jwt/login" - mock.post( - auth_url, - additional_matcher=match_body, - status_code=200, - json={"access": "access"}, - ) - - providerPassword = GCoreProvider( - "test_id", - url="http://dns", - auth_url="http://api", - login="foo", - password="bar", - ) - assert mock.called - - # make sure token passed in header - zone_rrset_url = "http://dns/zones/unit.tests/rrsets?all=true" - mock.get( - zone_rrset_url, - request_headers={"Authorization": "Bearer access"}, - status_code=404, - ) - zone = Zone("unit.tests.", []) - assert not providerPassword.populate(zone) - - # TC: No diffs == no changes - with requests_mock() as mock: - base = "https://dnsapi.gcorelabs.com/v2/zones/unit.tests/rrsets" - with open("tests/fixtures/gcore-no-changes.json") as fh: - mock.get(base, text=fh.read()) - - zone = Zone("unit.tests.", []) - provider.populate(zone) - self.assertEqual(14, len(zone.records)) - self.assertEqual( - { - "", - "_imap._tcp", - "_pop3._tcp", - "_srv._tcp", - "aaaa", - "cname", - "excluded", - "mx", - "ptr", - "sub", - "txt", - "www", - "www.sub", - }, - {r.name for r in zone.records}, - ) - changes = self.expected.changes(zone, provider) - self.assertEqual(0, len(changes)) - - # TC: 4 create (dynamic) + 1 removed + 7 modified - with requests_mock() as mock: - base = "https://dnsapi.gcorelabs.com/v2/zones/unit.tests/rrsets" - with open("tests/fixtures/gcore-records.json") as fh: - mock.get(base, text=fh.read()) - - zone = Zone("unit.tests.", []) - provider.populate(zone) - self.assertEqual(16, len(zone.records)) - changes = self.expected.changes(zone, provider) - self.assertEqual(11, len(changes)) - self.assertEqual( - 3, len([c for c in changes if isinstance(c, Create)]) - ) - self.assertEqual( - 1, len([c for c in changes if isinstance(c, Delete)]) - ) - self.assertEqual( - 7, len([c for c in changes if isinstance(c, Update)]) - ) - - # TC: no pools can be built - with requests_mock() as mock: - base = "https://dnsapi.gcorelabs.com/v2/zones/unit.tests/rrsets" - mock.get( - base, - json={ - "rrsets": [ - { - "name": "unit.tests.", - "type": "A", - "ttl": 300, - "filters": self.default_filters, - "resource_records": [{"content": ["7.7.7.7"]}], - } - ] - }, - ) - - zone = Zone("unit.tests.", []) - with self.assertRaises(RuntimeError) as ctx: - provider.populate(zone) - - self.assertTrue( - str(ctx.exception).startswith( - "filter is enabled, but no pools where built for" - ), - f"{ctx.exception} - is not start from desired text", - ) - - def test_apply(self): - provider = GCoreProvider("test_id", url="http://api", token="token") - - # TC: Zone does not exists but can be created. - with requests_mock() as mock: - mock.get( - ANY, status_code=404, text='{"error":"zone is not found"}' - ) - mock.post(ANY, status_code=200, text='{"id":1234}') - - plan = provider.plan(self.expected) - provider.apply(plan) - - # TC: Zone does not exists and can't be created. - with requests_mock() as mock: - mock.get( - ANY, status_code=404, text='{"error":"zone is not found"}' - ) - mock.post( - ANY, - status_code=400, - text='{"error":"parent zone is already' - ' occupied by another client"}', - ) - - with self.assertRaises( - (GCoreClientNotFound, GCoreClientBadRequest) - ) as ctx: - plan = provider.plan(self.expected) - provider.apply(plan) - self.assertIn( - "parent zone is already occupied by another client", - str(ctx.exception), - ) - - resp = Mock() - resp.json = Mock() - provider._client._request = Mock(return_value=resp) - - with open("tests/fixtures/gcore-zone.json") as fh: - zone = fh.read() - - # non-existent domain - resp.json.side_effect = [ - GCoreClientNotFound(resp), # no zone in populate - GCoreClientNotFound(resp), # no domain during apply - zone, - ] - plan = provider.plan(self.expected) - - # TC: create all - self.assertEqual(13, len(plan.changes)) - self.assertEqual(13, provider.apply(plan)) - self.assertFalse(plan.exists) - - provider._client._request.assert_has_calls( - [ - call( - "GET", - "http://api/zones/unit.tests/rrsets", - params={"all": "true"}, - ), - call("GET", "http://api/zones/unit.tests"), - call("POST", "http://api/zones", data={"name": "unit.tests"}), - call( - "POST", - "http://api/zones/unit.tests/www.sub.unit.tests./A", - data={ - "ttl": 300, - "resource_records": [{"content": ["2.2.3.6"]}], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/www.unit.tests./A", - data={ - "ttl": 300, - "resource_records": [{"content": ["2.2.3.6"]}], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/txt.unit.tests./TXT", - data={ - "ttl": 600, - "resource_records": [ - {"content": ["Bah bah black sheep"]}, - {"content": ["have you any wool."]}, - { - "content": [ - "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+" - "of/long/string+with+numb3rs" - ] - }, - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/sub.unit.tests./NS", - data={ - "ttl": 3600, - "resource_records": [ - {"content": ["6.2.3.4."]}, - {"content": ["7.2.3.4."]}, - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/ptr.unit.tests./PTR", - data={ - "ttl": 300, - "resource_records": [ - {"content": ["foo.bar.com."]}, - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/mx.unit.tests./MX", - data={ - "ttl": 300, - "resource_records": [ - {"content": [10, "smtp-4.unit.tests."]}, - {"content": [20, "smtp-2.unit.tests."]}, - {"content": [30, "smtp-3.unit.tests."]}, - {"content": [40, "smtp-1.unit.tests."]}, - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/excluded.unit.tests./CNAME", - data={ - "ttl": 3600, - "resource_records": [{"content": ["unit.tests."]}], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/cname.unit.tests./CNAME", - data={ - "ttl": 300, - "resource_records": [{"content": ["unit.tests."]}], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/aaaa.unit.tests./AAAA", - data={ - "ttl": 600, - "resource_records": [ - { - "content": [ - "2601:644:500:e210:62f8:1dff:feb8:947a" - ] - } - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/_srv._tcp.unit.tests./SRV", - data={ - "ttl": 600, - "resource_records": [ - {"content": [10, 20, 30, "foo-1.unit.tests."]}, - {"content": [12, 20, 30, "foo-2.unit.tests."]}, - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/_pop3._tcp.unit.tests./SRV", - data={ - "ttl": 600, - "resource_records": [{"content": [0, 0, 0, "."]}], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/_imap._tcp.unit.tests./SRV", - data={ - "ttl": 600, - "resource_records": [{"content": [0, 0, 0, "."]}], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/unit.tests./A", - data={ - "ttl": 300, - "resource_records": [ - {"content": ["1.2.3.4"]}, - {"content": ["1.2.3.5"]}, - ], - }, - ), - ] - ) - # expected number of total calls - self.assertEqual(16, provider._client._request.call_count) - - # TC: delete 1 and update 1 - provider._client._request.reset_mock() - provider._client.zone_records = Mock( - return_value=[ - { - "name": "www", - "ttl": 300, - "type": "A", - "resource_records": [{"content": ["1.2.3.4"]}], - }, - { - "name": "ttl", - "ttl": 600, - "type": "A", - "resource_records": [{"content": ["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.assertEqual(2, len(plan.changes)) - self.assertEqual(2, provider.apply(plan)) - - provider._client._request.assert_has_calls( - [ - call( - "DELETE", "http://api/zones/unit.tests/www.unit.tests./A" - ), - call( - "PUT", - "http://api/zones/unit.tests/ttl.unit.tests./A", - data={ - "ttl": 300, - "resource_records": [{"content": ["3.2.3.4"]}], - }, - ), - ] - ) - - # TC: create dynamics - provider._client._request.reset_mock() - provider._client.zone_records = Mock(return_value=[]) - - # Domain exists, we don't care about return - resp.json.side_effect = ["{}"] - - wanted = Zone("unit.tests.", []) - wanted.add_record( - Record.new( - wanted, - "geo-simple", - { - "ttl": 300, - "type": "A", - "value": "3.3.3.3", - "dynamic": { - "pools": { - "pool-1": { - "fallback": "other", - "values": [ - {"value": "1.1.1.1"}, - {"value": "1.1.1.2"}, - ], - }, - "pool-2": { - "fallback": "other", - "values": [ - {"value": "2.2.2.1"}, - ], - }, - "other": {"values": [{"value": "3.3.3.3"}]}, - }, - "rules": [ - {"pool": "pool-1", "geos": ["EU-RU"]}, - {"pool": "pool-2", "geos": ["EU"]}, - {"pool": "other"}, - ], - }, - }, - ), - ) - wanted.add_record( - Record.new( - wanted, - "geo-defaults", - { - "ttl": 300, - "type": "A", - "value": "3.2.3.4", - "dynamic": { - "pools": { - "pool-1": { - "values": [ - {"value": "2.2.2.1"}, - ], - }, - }, - "rules": [ - {"pool": "pool-1", "geos": ["EU"]}, - ], - }, - }, - ), - ) - wanted.add_record( - Record.new( - wanted, - "cname-smpl", - { - "ttl": 300, - "type": "CNAME", - "value": "en.unit.tests.", - "dynamic": { - "pools": { - "pool-1": { - "fallback": "other", - "values": [ - {"value": "ru-1.unit.tests."}, - {"value": "ru-2.unit.tests."}, - ], - }, - "pool-2": { - "fallback": "other", - "values": [ - {"value": "eu.unit.tests."}, - ], - }, - "other": {"values": [{"value": "en.unit.tests."}]}, - }, - "rules": [ - {"pool": "pool-1", "geos": ["EU-RU"]}, - {"pool": "pool-2", "geos": ["EU"]}, - {"pool": "other"}, - ], - }, - }, - ), - ) - wanted.add_record( - Record.new( - wanted, - "cname-dflt", - { - "ttl": 300, - "type": "CNAME", - "value": "en.unit.tests.", - "dynamic": { - "pools": { - "pool-1": { - "values": [ - {"value": "eu.unit.tests."}, - ], - }, - }, - "rules": [ - {"pool": "pool-1", "geos": ["EU"]}, - ], - }, - }, - ), - ) - - plan = provider.plan(wanted) - self.assertTrue(plan.exists) - self.assertEqual(4, len(plan.changes)) - self.assertEqual(4, provider.apply(plan)) - - provider._client._request.assert_has_calls( - [ - call( - "POST", - "http://api/zones/unit.tests/geo-simple.unit.tests./A", - data={ - "ttl": 300, - "filters": self.default_filters, - "resource_records": [ - { - "content": ["1.1.1.1"], - "meta": {"countries": ["RU"]}, - }, - { - "content": ["1.1.1.2"], - "meta": {"countries": ["RU"]}, - }, - { - "content": ["2.2.2.1"], - "meta": {"continents": ["EU"]}, - }, - { - "content": ["3.3.3.3"], - "meta": {"default": True}, - }, - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/geo-defaults.unit.tests./A", - data={ - "ttl": 300, - "filters": self.default_filters, - "resource_records": [ - { - "content": ["2.2.2.1"], - "meta": {"continents": ["EU"]}, - }, - { - "content": ["3.2.3.4"], - }, - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/cname-smpl.unit.tests./CNAME", - data={ - "ttl": 300, - "filters": self.default_filters, - "resource_records": [ - { - "content": ["ru-1.unit.tests."], - "meta": {"countries": ["RU"]}, - }, - { - "content": ["ru-2.unit.tests."], - "meta": {"countries": ["RU"]}, - }, - { - "content": ["eu.unit.tests."], - "meta": {"continents": ["EU"]}, - }, - { - "content": ["en.unit.tests."], - "meta": {"default": True}, - }, - ], - }, - ), - call( - "POST", - "http://api/zones/unit.tests/cname-dflt.unit.tests./CNAME", - data={ - "ttl": 300, - "filters": self.default_filters, - "resource_records": [ - { - "content": ["eu.unit.tests."], - "meta": {"continents": ["EU"]}, - }, - { - "content": ["en.unit.tests."], - }, - ], - }, - ), - ] - ) + def test_missing(self): + with self.assertRaises(ModuleNotFoundError): + from octodns.provider.gcore import GCoreProvider + GCoreProvider