diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d9543..fe1a30d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## v0.0.8 - 2024-??-?? - * DNAME, DS, and TLSA record type support added. +* Validate that healthcheck protocol is supported (HTTP, HTTPS, ICMP, TCP) ## v0.0.7 - 2023-11-14 - Maintenance release diff --git a/octodns_ns1/__init__.py b/octodns_ns1/__init__.py index 6ed9a38..324eb8a 100644 --- a/octodns_ns1/__init__.py +++ b/octodns_ns1/__init__.py @@ -12,7 +12,7 @@ from ns1 import NS1 from ns1.rest.errors import RateLimitException, ResourceException -from octodns.provider import ProviderException +from octodns.provider import ProviderException, SupportsException from octodns.provider.base import BaseProvider from octodns.record import Create, Record, Update from octodns.record.geo import GeoCodes @@ -1029,6 +1029,17 @@ def populate(self, zone, target=False, lenient=False): ) return exists + def _process_desired_zone(self, desired): + for record in desired.records: + if getattr(record, 'dynamic', False): + protocol = record.healthcheck_protocol + if protocol not in ('HTTP', 'HTTPS', 'ICMP', 'TCP'): + msg = f'healthcheck protocol "{protocol}" not supported' + # no workable fallbacks so straight error + raise SupportsException(f'{self.id}: {msg}') + + return super()._process_desired_zone(desired) + def _params_for_geo_A(self, record): # purposefully set non-geo answers to have an empty meta, # so that we know we did this on purpose if/when troubleshooting @@ -1235,7 +1246,17 @@ def _monitor_gen(self, record, value): connect_timeout = self._healthcheck_connect_timeout(record) response_timeout = self._healthcheck_response_timeout(record) - if record.healthcheck_protocol == 'TCP' or not self.use_http_monitors: + healthcheck_protocol = record.healthcheck_protocol + if healthcheck_protocol == 'ICMP': + ret['job_type'] = 'ping' + ret['config'] = { + 'count': 4, + 'host': value, + 'interval': response_timeout * 250, # 1/4 response_timeout + 'ipv6': _type == 'AAAA', + 'timeout': response_timeout * 1000, + } + elif healthcheck_protocol == 'TCP' or not self.use_http_monitors: ret['job_type'] = 'tcp' ret['config'] = { 'host': value, @@ -1243,10 +1264,10 @@ def _monitor_gen(self, record, value): # TCP monitors use milliseconds, so convert from seconds to milliseconds 'connect_timeout': connect_timeout * 1000, 'response_timeout': response_timeout * 1000, - 'ssl': record.healthcheck_protocol == 'HTTPS', + 'ssl': healthcheck_protocol == 'HTTPS', } - if record.healthcheck_protocol != 'TCP': + if healthcheck_protocol != 'TCP': # legacy HTTP-emulating TCP monitor # we need to send the HTTP request string path = record.healthcheck_path @@ -1268,7 +1289,7 @@ def _monitor_gen(self, record, value): else: # modern HTTP monitor ret['job_type'] = 'http' - proto = record.healthcheck_protocol.lower() + proto = healthcheck_protocol.lower() domain = f'[{value}]' if _type == 'AAAA' else value port = record.healthcheck_port path = record.healthcheck_path diff --git a/tests/test_provider_ns1.py b/tests/test_provider_ns1.py index 91a6c33..5246553 100644 --- a/tests/test_provider_ns1.py +++ b/tests/test_provider_ns1.py @@ -8,6 +8,7 @@ from ns1.rest.errors import AuthException, RateLimitException, ResourceException +from octodns.provider import SupportsException from octodns.provider.plan import Plan from octodns.record import Delete, Record, Update from octodns.zone import Zone @@ -1254,6 +1255,33 @@ def test_monitor_gen_CNAME_http(self): monitor = provider._monitor_gen(record, value) self.assertTrue(value[:-1] in monitor['config']['url']) + def test_monitor_gen_ICMP(self): + provider = Ns1Provider('test', 'api-key', use_http_monitors=True) + + value = '1.2.3.4' + record = self.record() + record._octodns['healthcheck']['protocol'] = 'ICMP' + monitor = provider._monitor_gen(record, value) + self.assertEqual('ping', monitor['job_type']) + self.assertEqual( + provider._healthcheck_response_timeout(record) * 1000, + monitor['config']['timeout'], + ) + self.assertEqual( + provider._healthcheck_response_timeout(record) * 250, + monitor['config']['interval'], + ) + self.assertFalse(monitor['config']['ipv6']) + self.assertTrue(value in monitor['config']['host']) + + value = '::ffff:3.4.5.6' + record = self.aaaa_record() + record._octodns['healthcheck']['protocol'] = 'ICMP' + monitor = provider._monitor_gen(record, value) + self.assertEqual('ping', monitor['job_type']) + self.assertTrue(monitor['config']['ipv6']) + self.assertTrue(value in monitor['config']['host']) + def test_monitor_is_match(self): provider = Ns1Provider('test', 'api-key') @@ -1306,6 +1334,41 @@ def test_monitor_is_match(self): ) ) + def test_unsupported_healthcheck_protocol(self): + provider = Ns1Provider('test', 'api-key') + desired = Zone('unit.tests.', []) + record = Record.new( + desired, + 'a', + { + 'ttl': 30, + 'type': 'A', + 'value': '1.2.3.4', + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '1.2.3.4'}]}, + 'two': {'values': [{'value': '2.2.3.4'}]}, + }, + 'rules': [ + {'geos': ['EU', 'NA-CA-NB', 'NA-US-OR'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'octodns': {'healthcheck': {'protocol': 'UDP'}}, + }, + lenient=True, + ) + desired.add_record(record) + with self.assertRaises(SupportsException) as ctx: + provider._process_desired_zone(desired) + self.assertEqual( + 'test: healthcheck protocol "UDP" not supported', str(ctx.exception) + ) + + record.octodns['healthcheck']['protocol'] = 'ICMP' + got = provider._process_desired_zone(desired) + self.assertEqual(got.records, desired.records) + @patch('octodns_ns1.Ns1Provider._feed_create') @patch('octodns_ns1.Ns1Provider._monitor_delete') @patch('octodns_ns1.Ns1Client.monitors_update')