diff --git a/octodns_gcore/__init__.py b/octodns_gcore/__init__.py index 7124dec..17aca34 100644 --- a/octodns_gcore/__init__.py +++ b/octodns_gcore/__init__.py @@ -12,7 +12,7 @@ from octodns import __VERSION__ as octodns_version from octodns.provider import ProviderException from octodns.provider.base import BaseProvider -from octodns.record import GeoCodes, Record +from octodns.record import GeoCodes, Record, Update # TODO: remove __VERSION__ with the next major version release __version__ = __VERSION__ = '0.0.4' @@ -227,7 +227,7 @@ def _build_rules(self, pools, geo_sets): return sorted(rules, key=lambda x: x["pool"]) - def _data_for_dynamic(self, record, value_transform_fn=lambda x: x): + def _data_for_dynamic_geo(self, record, value_transform_fn=lambda x: x): default_pool = "other" pools, geo_sets, defaults = self._build_pools( record, default_pool, value_transform_fn @@ -271,7 +271,7 @@ 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( + pools, rules, defaults = self._data_for_dynamic_geo( record, self._add_dot_if_need ) return { @@ -283,12 +283,25 @@ def _data_for_CNAME(self, _type, record): 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, - } + filters = record.get("filters") + if filters is not None: + filter_types = [filter['type'] for filter in filters] + if 'geodns' in filter_types: + pools, rules, defaults = self._data_for_dynamic_geo(record) + extra = { + "dynamic": {"pools": pools, "rules": rules}, + "values": defaults, + } + else: + # other type should be "healthcheck" + pools, rules, octodns, defaults = ( + self._data_dynamic_healthcheck(record) + ) + extra = { + 'dynamic': {'pools': pools, 'rules': [{'pool': 'pool-0'}]}, + 'octodns': octodns, + "values": defaults, + } else: extra = { "values": [ @@ -368,6 +381,62 @@ def _data_for_CAA(self, _type, record): ], } + def _data_dynamic_healthcheck(self, record): + defaults = [ + resource['content'][0] for resource in record["resource_records"] + ] + + pools = { + 'pool-0': { + 'values': [ + { + 'value': resource['content'][0], + 'status': 'obey', + # 'status': 'up' if resource['enabled'] else 'down', + } + for resource in record["resource_records"] + ] + } + } + + check_params = record['meta']['failover'] + # gcore_api_meta_body = { + # "meta": { + # "failover": { + # "frequency": 30, + # "host": "dns-monitor.tld", + # "http_status_code": 200, + # "method": "GET", + # "port": 80, + # "protocol": "HTTP", + # "regexp": "ok", + # "timeout": 10, + # "tls": False, + # "url": "/dns-monitor" } } + octodns = { + 'healthcheck': { + 'host': check_params['host'], + # path properties is associated with url gcore param + 'path': check_params['url'], + 'port': check_params['port'], + 'protocol': check_params['protocol'], + }, + 'gcore': { + 'healthcheck': { + 'frequency': check_params['frequency'], + 'http_status_code': check_params['http_status_code'], + 'method': check_params['method'], + 'regexp': check_params['regexp'], + 'timeout': check_params['timeout'], + 'tls': check_params['tls'], + } + }, + } + rules = [{'pool': 'pool-0'}] + self.log.debug('_data_dynamic_healthcheck = %s', str(octodns)) + + return pools, rules, octodns, defaults + def zone_records(self, zone): try: return self._client.zone_records(zone.name[:-1]), True @@ -419,8 +488,13 @@ 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", []) + types = [v.get("type") for v in filters] + + if 'healthcheck' in types or ' ' in types: + return False + + want_filters = 3 if len(filters) != want_filters: self.log.info( "ignore %s has filters and their count is not %d", @@ -428,7 +502,6 @@ def _should_ignore(self, record): 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( @@ -450,7 +523,28 @@ def _should_ignore(self, record): return True return False - def _params_for_dymanic(self, record): + def _params_for_dynamic_healthcheck(self, record): + """Return ressource records for dynamic healthcheck dynamic records + + Cf. https://api.gcore.com/docs/dns#tag/RRsets/operation/UpdateRRSet + + Args: + record (Record): _description_ + + Returns: + Resource_records: Array of objects (InputResourceRecord) [ items ] + """ + + resource_records = [] + + for value in record.dynamic.pools['pool-0'].data["values"]: + v = value["value"] + resource_records.append( + {"content": [v], "enabled": True, "meta": {}} + ) + return resource_records + + def _params_for_dynamic_geo(self, record): records = [] default_pool_found = False default_values = set( @@ -504,7 +598,7 @@ def _params_for_CNAME(self, record): return { "ttl": record.ttl, - "resource_records": self._params_for_dymanic(record), + "resource_records": self._params_for_dynamic_geo(record), "filters": [ {"type": "geodns"}, { @@ -518,8 +612,40 @@ def _params_for_CNAME(self, record): def _params_for_multiple(self, record): extra = dict() - if record.dynamic: - extra["resource_records"] = self._params_for_dymanic(record) + # If the Record is an healthcheck dynamic record + if record.octodns.get('healthcheck'): + + healthcheck = record.octodns['healthcheck'] + healthcheck.update(record.octodns['gcore']['healthcheck']) + # path is translated to url for Gcore API + healthcheck['url'] = healthcheck['path'] + healthcheck.pop('path') + + extra['meta'] = {'failover': healthcheck} + # meta = { + # "failover": { + # "frequency": 180, + # "host": "gcore-test.tld", + # "http_status_code": 200, + # "method": "GET", + # "port": 80, + # "protocol": "HTTP", + # "regexp": "ok", + # "timeout": 10, + # "tls": False, + # "url": "/ns1-monitor", } } + extra['pickers'] = [ + {"type": "healthcheck", "strict": False}, + {"type": "weighted_shuffle", "strict": False}, + ] + + extra["resource_records"] = self._params_for_dynamic_healthcheck( + record + ) + + # If the Record is a GeoDNS dynamic record + elif record.dynamic: + extra["resource_records"] = self._params_for_dynamic_geo(record) extra["filters"] = [ {"type": "geodns"}, { @@ -533,6 +659,7 @@ def _params_for_multiple(self, record): extra["resource_records"] = [ {"content": [value]} for value in record.values ] + return {"ttl": record.ttl, **extra} _params_for_A = _params_for_multiple @@ -594,6 +721,7 @@ def _apply_update(self, change): self.log.info("updating: %s", change) new = change.new data = getattr(self, f"_params_for_{new._type}")(new) + self.log.debug("updating: %s", str(data)) self._client.record_update( new.zone.name[:-1], new.fqdn, new._type, data ) @@ -605,6 +733,49 @@ def _apply_delete(self, change): existing.zone.name[:-1], existing.fqdn, existing._type ) + def _extra_changes(self, existing, desired, changes): + self.log.debug( + "_extra_changes: existing=%s, desired=%s, (changes)=%d", + existing.name, + desired.name, + len(changes), + ) + ''' + An opportunity for providers to add extra changes to the plan that are + necessary to update ancillary record data or configure the zone. E.g. + base NS records. + ''' + extra = [] + desired_records = {r: r for r in desired.records} + changed = set([c.record for c in changes]) + + for record in existing.records: + if not getattr(record, 'dynamic', False): + # no need to check non-dynamic simple records + continue + + update = False + + desired_record = desired_records[record] + if record.octodns == desired_record.octodns: + continue + update = True + + self.log.debug( + "_extra_changes: Existing record=%s, \noctodns=%s", + str(record.fqdn), + str(record.octodns), + ) + self.log.debug( + "_extra_changes: Desired record=%s, \noctodns=%s", + str(desired_record.fqdn), + str(desired_record.octodns), + ) + if update and record not in changed: + extra.append(Update(record, desired_record)) + + return extra + def _apply(self, plan): desired = plan.desired changes = plan.changes diff --git a/tests/fixtures/gcore-records.json b/tests/fixtures/gcore-records.json index b766523..c0bb54a 100644 --- a/tests/fixtures/gcore-records.json +++ b/tests/fixtures/gcore-records.json @@ -437,6 +437,201 @@ ] } ] + }, + { + "id": 123456, + "name": "healthcheck.unit.tests.", + "type": "A", + "ttl": 30, + "meta": { + "failover": { + "frequency": 60, + "host": "gcore-test.tld", + "http_status_code": 200, + "method": "GET", + "path": "/monitor", + "port": 80, + "protocol": "HTTP", + "regexp": "ok", + "timeout": 10, + "tls": false, + "url": "/monitor" + } + }, + "updated_at": 1713802438584317000, + "filter_set_id": 234567, + "filters": [ + { + "type": "healthcheck", + "strict": false + }, + { + "type": "weighted_shuffle", + "strict": false + } + ], + "pickers": [ + { + "type": "healthcheck", + "strict": false + }, + { + "type": "weighted_shuffle", + "strict": false + } + ], + "resource_records": [ + { + "id": 8765432, + "content": [ + "1.2.3.4" + ], + "enabled": true, + "meta": {} + }, + { + "id": 7654321, + "content": [ + "5.6.7.8" + ], + "enabled": true, + "meta": {} + } + ] + }, + { + "name": "healthcheck-update.unit.tests.", + "type": "A", + "ttl": 300, + "meta": { + "failover": { + "host": "gcore-test.tld", + "path": "/dns-monitor", + "port": 80, + "protocol": "HTTP", + "frequency": 300, + "http_status_code": 200, + "method": "GET", + "regexp": "ok", + "timeout": 10, + "tls": false, + "url": "/dns-monitor" + } + }, + "pickers": [ + { + "type": "healthcheck", + "strict": false + }, + { + "type": "weighted_shuffle", + "strict": false + } + ], + "resource_records": [ + { + "content": [ + "1.2.3.4" + ], + "enabled": true, + "meta": {} + }, + { + "content": [ + "5.6.7.8" + ], + "enabled": true, + "meta": {} + } + ] + }, + { + "name": "healthcheck-not-ignored.unit.tests.", + "type": "A", + "ttl": 300, + "meta": { + "failover": { + "host": "gcore-test.tld", + "path": "/dns-monitor", + "port": 80, + "protocol": "HTTP", + "frequency": 300, + "http_status_code": 200, + "method": "GET", + "regexp": "ok", + "timeout": 10, + "tls": false, + "url": "/dns-monitor" + } + }, + "pickers": [ + { + "type": "distance", + "strict": false + }, + { + "type": "weighted_shuffle", + "strict": false + } + ], + "resource_records": [ + { + "content": [ + "1.2.3.4" + ], + "enabled": true, + "meta": {} + }, + { + "content": [ + "5.6.7.8" + ], + "enabled": true, + "meta": {} + } + ] + }, + { + "name": "healthcheck-ignored.unit.tests.", + "type": "A", + "ttl": 300, + "meta": { + "failover": { + "host": "gcore-test.tld", + "path": "/monitor", + "port": 80, + "protocol": "HTTP", + "frequency": 300, + "http_status_code": 200, + "method": "GET", + "regexp": "ok", + "timeout": 10, + "tls": false + } + }, + "filters": [ + { + "type": "distance", + "strict": false + } + ], + "resource_records": [ + { + "content": [ + "1.2.3.4" + ], + "enabled": true, + "meta": {} + }, + { + "content": [ + "5.6.7.8" + ], + "enabled": true, + "meta": {} + } + ] } + ] } \ No newline at end of file diff --git a/tests/test_octodns_provider_gcore.py b/tests/test_octodns_provider_gcore.py index 8c3eef0..6cb8638 100644 --- a/tests/test_octodns_provider_gcore.py +++ b/tests/test_octodns_provider_gcore.py @@ -143,11 +143,11 @@ def match_body(request): zone = Zone("unit.tests.", []) provider.populate(zone, lenient=True) - self.assertEqual(17, len(zone.records)) + self.assertEqual(20, len(zone.records)) changes = self.expected.changes(zone, provider) - self.assertEqual(11, len(changes)) + self.assertEqual(14, len(changes)) self.assertEqual( - 3, len([c for c in changes if isinstance(c, Create)]) + 6, len([c for c in changes if isinstance(c, Create)]) ) self.assertEqual( 1, len([c for c in changes if isinstance(c, Delete)]) @@ -721,13 +721,92 @@ def test_apply(self): ) ) + wanted.add_record( + Record.new( + wanted, + "a-healthcheck-simple", + { + "ttl": 300, + "type": "A", + "value": ['11.22.33.44', '55.66.77.88'], + 'dynamic': { + 'pools': { + 'pool-0': { + 'values': [ + {'value': '11.22.33.44', 'status': 'obey'}, + {'value': '55.66.77.88', 'status': 'obey'}, + ] + } + }, + 'rules': [{'pool': 'pool-0'}], + }, + 'octodns': { + "healthcheck": { + "host": "gcore-test.tld", + "path": "/dns-monitor", + "port": 80, + "protocol": "HTTP", + }, + 'gcore': { + "healthcheck": { + "frequency": 300, + "http_status_code": 200, + "method": "GET", + "regexp": "ok", + "timeout": 10, + "tls": False, + } + }, + }, + }, + lenient=True, + ) + ) + plan = provider.plan(wanted) self.assertTrue(plan.exists) - self.assertEqual(4, len(plan.changes)) - self.assertEqual(4, provider.apply(plan)) + self.assertEqual(5, len(plan.changes)) + self.assertEqual(5, provider.apply(plan)) provider._client._request.assert_has_calls( [ + call( + "POST", + "http://api/zones/unit.tests/a-healthcheck-simple.unit.tests./A", + data={ + "ttl": 300, + "meta": { + "failover": { + "host": "gcore-test.tld", + "port": 80, + "protocol": "HTTP", + "frequency": 300, + "http_status_code": 200, + "method": "GET", + "regexp": "ok", + "timeout": 10, + "tls": False, + "url": "/dns-monitor", + } + }, + "pickers": [ + {"type": "healthcheck", "strict": False}, + {"type": "weighted_shuffle", "strict": False}, + ], + "resource_records": [ + { + "content": ["11.22.33.44"], + "enabled": True, + "meta": {}, + }, + { + "content": ["55.66.77.88"], + "enabled": True, + "meta": {}, + }, + ], + }, + ), call( "POST", "http://api/zones/unit.tests/cname-dflt.unit.tests./CNAME", @@ -810,6 +889,263 @@ def test_apply(self): ] ) + # Check Healthcheck extra changes + provider._client._request.reset_mock() + provider._client.zone_records = Mock( + return_value=[ + { + "name": "a-healthcheck-simple", + "ttl": 300, + "type": "A", + "meta": { + "failover": { + "frequency": 60, + "host": "dns-monitor.tld", + "http_status_code": 200, + "method": "GET", + "port": 80, + "protocol": "HTTP", + "regexp": "ok", + "timeout": 10, + "tls": False, + "url": "/dns-monitor", + } + }, + "filters": [ + {"type": "healthcheck", "strict": "false"}, + {"type": "weighted_shuffle", "strict": "false"}, + ], + "pickers": [ + {"type": "healthcheck", "strict": "false"}, + {"type": "weighted_shuffle", "strict": "false"}, + ], + "resource_records": [ + { + "content": ["11.22.33.44"], + "enabled": "true", + "meta": {}, + }, + { + "content": ["55.66.77.88"], + "enabled": "true", + "meta": {}, + }, + ], + }, + { + "name": "a-healthcheck-simple-changed-ttl", + "ttl": 300, + "type": "A", + "meta": { + "failover": { + "frequency": 60, + "host": "dns-monitor.tld", + "http_status_code": 200, + "method": "GET", + "port": 80, + "protocol": "HTTP", + "regexp": "ok", + "timeout": 10, + "tls": False, + "url": "/dns-monitor", + } + }, + "filters": [ + {"type": "healthcheck", "strict": "false"}, + {"type": "weighted_shuffle", "strict": "false"}, + ], + "pickers": [ + {"type": "healthcheck", "strict": "false"}, + {"type": "weighted_shuffle", "strict": "false"}, + ], + "resource_records": [ + { + "content": ["11.22.33.44"], + "enabled": "true", + "meta": {}, + }, + { + "content": ["55.66.77.88"], + "enabled": "true", + "meta": {}, + }, + ], + }, + { + "name": "a-healthcheck-simple-changed-ttl-and-freq", + "ttl": 300, + "type": "A", + "meta": { + "failover": { + "frequency": 60, + "host": "dns-monitor.tld", + "http_status_code": 200, + "method": "GET", + "port": 80, + "protocol": "HTTP", + "regexp": "ok", + "timeout": 10, + "tls": False, + "url": "/dns-monitor", + } + }, + "filters": [ + {"type": "healthcheck", "strict": "false"}, + {"type": "weighted_shuffle", "strict": "false"}, + ], + "pickers": [ + {"type": "healthcheck", "strict": "false"}, + {"type": "weighted_shuffle", "strict": "false"}, + ], + "resource_records": [ + { + "content": ["11.22.33.44"], + "enabled": "true", + "meta": {}, + }, + { + "content": ["55.66.77.88"], + "enabled": "true", + "meta": {}, + }, + ], + }, + ] + ) + + resp.json.side_effect = ["{}"] + + wanted = Zone("unit.tests.", []) + # modified healthcheck host : + wanted.add_record( + Record.new( + wanted, + "a-healthcheck-simple", + { + "ttl": 300, + "type": "A", + "value": ['11.22.33.44', '55.66.77.88'], + 'dynamic': { + 'pools': { + 'pool-0': { + 'values': [ + {'value': '11.22.33.44', 'status': 'obey'}, + {'value': '55.66.77.88', 'status': 'obey'}, + ] + } + }, + 'rules': [{'pool': 'pool-0'}], + }, + 'octodns': { + "healthcheck": { + "host": "gcore-test.tld", + "path": "/dns-monitor", + "port": 80, + "protocol": "HTTP", + }, + 'gcore': { + "healthcheck": { + "frequency": 60, + "http_status_code": 200, + "method": "GET", + "regexp": "ok", + "timeout": 10, + "tls": False, + } + }, + }, + }, + lenient=True, + ) + ) + wanted.add_record( + Record.new( + wanted, + "a-healthcheck-simple-changed-ttl", + { + "ttl": 60, + "type": "A", + "value": ['11.22.33.44', '55.66.77.88'], + 'dynamic': { + 'pools': { + 'pool-0': { + 'values': [ + {'value': '11.22.33.44', 'status': 'obey'}, + {'value': '55.66.77.88', 'status': 'obey'}, + ] + } + }, + 'rules': [{'pool': 'pool-0'}], + }, + 'octodns': { + "healthcheck": { + "host": "dns-monitor.tld", + "path": "/dns-monitor", + "port": 80, + "protocol": "HTTP", + }, + 'gcore': { + "healthcheck": { + "frequency": 60, + "http_status_code": 200, + "method": "GET", + "regexp": "ok", + "timeout": 10, + "tls": False, + } + }, + }, + }, + lenient=True, + ) + ) + wanted.add_record( + Record.new( + wanted, + "a-healthcheck-simple-changed-ttl-and-freq", + { + "ttl": 60, + "type": "A", + "value": ['11.22.33.44', '55.66.77.88'], + 'dynamic': { + 'pools': { + 'pool-0': { + 'values': [ + {'value': '11.22.33.44', 'status': 'obey'}, + {'value': '55.66.77.88', 'status': 'obey'}, + ] + } + }, + 'rules': [{'pool': 'pool-0'}], + }, + 'octodns': { + "healthcheck": { + "host": "dns-monitor.tld", + "path": "/dns-monitor", + "port": 80, + "protocol": "HTTP", + }, + 'gcore': { + "healthcheck": { + "frequency": 120, + "http_status_code": 200, + "method": "GET", + "regexp": "ok", + "timeout": 10, + "tls": False, + } + }, + }, + }, + lenient=True, + ) + ) + + plan = provider.plan(wanted) + self.assertTrue(plan.exists) + self.assertEqual(3, len(plan.changes)) + self.assertEqual(3, provider.apply(plan)) + def test_provider_hierarchy(self): provider = GCoreProvider("test_id", token="token") self.assertIsInstance(provider, _BaseProvider) @@ -898,3 +1234,48 @@ def test__process_desired_zone_not_dynamic(self): and not g.startswith("NA-CA-") ] ) + + def test__process_desired_zone_dynamic_healthcheck(self): + provider = GCoreProvider( + "test_id", token="token", strict_supports=False + ) + for geo, prefix_name in [("NA-US-CA", "default")]: + data = { + "type": "A", + "ttl": 60, + "values": ["1.2.3.4"], + 'dynamic': { + 'pools': {'pool-0': {'values': [{'value': '1.2.3.4'}]}}, + 'rules': [{'pool': 'pool-0'}], + 'octodns': { + 'healthcheck': { + 'host': "gcore-test.tld", + 'path': "/monitor", + 'port': 80, + 'protocol': "HTTP", + }, + 'gcore': { + "healthcheck": { + 'frequency': 300, + 'http_status_code': 200, + 'method': "GET", + 'regexp': 'ok', + 'timeout': 10, + 'tls': False, + } + }, + }, + }, + } + zone1 = Zone("unit.tests.", []) + record1 = Record.new(zone1, prefix_name, data=data, lenient=True) + + zone1.add_record(record1) + result = provider._process_desired_zone(zone1.copy()) + for record in result.records: + + for rule in record.dynamic.rules: + pool = rule.data.get("pool") + + value0 = record.dynamic.pools[pool].data["values"][0] + assert value0['value'] == record.values[0]