diff --git a/README.md b/README.md index 8820ce9..8ba9e42 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ providers: #cdn: false # Manage Page Rules (URLFWD) records # pagerules: true + # Optional. Define Cloudflare plan type for the zones. Default: free, + # options: free, enterprise + #plan_type: free # Optional. Default: 4. Number of times to retry if a 429 response # is received. #retry_count: 4 diff --git a/octodns_cloudflare/__init__.py b/octodns_cloudflare/__init__.py index 770bf9b..29e602c 100644 --- a/octodns_cloudflare/__init__.py +++ b/octodns_cloudflare/__init__.py @@ -89,6 +89,7 @@ def __init__( account_id=None, cdn=False, pagerules=True, + plan_type=None, retry_count=4, retry_period=300, auth_error_retry_count=0, @@ -100,11 +101,12 @@ def __init__( ): self.log = getLogger(f'CloudflareProvider[{id}]') self.log.debug( - '__init__: id=%s, email=%s, token=***, account_id=%s, cdn=%s', + '__init__: id=%s, email=%s, token=***, account_id=%s, cdn=%s, plan=%s', id, email, account_id, cdn, + plan_type, ) super().__init__(id, *args, **kwargs) @@ -123,6 +125,7 @@ def __init__( self.account_id = account_id self.cdn = cdn self.pagerules = pagerules + self.plan_type = plan_type self.retry_count = retry_count self.retry_period = retry_period self.auth_error_retry_count = auth_error_retry_count @@ -214,7 +217,15 @@ def zones(self): else: page = None - self._zones = IdnaDict({f'{z["name"]}.': z['id'] for z in zones}) + self._zones = IdnaDict( + { + f'{z["name"]}.': { + 'id': z['id'], + 'plan': z.get('plan', {}).get('legacy_id', None), + } + for z in zones + } + ) return self._zones @@ -468,7 +479,7 @@ def _data_for_SSHFP(self, _type, records): def zone_records(self, zone): if zone.name not in self._zone_records: - zone_id = self.zones.get(zone.name, False) + zone_id = self.zones.get(zone.name, {}).get('id', False) if not zone_id: return [] @@ -1016,7 +1027,7 @@ def _gen_key(self, data): def _apply_Create(self, change): new = change.new - zone_id = self.zones[new.zone.name] + zone_id = self.zones[new.zone.name]['id'] if new._type == 'URLFWD': path = f'/zones/{zone_id}/pagerules' else: @@ -1026,7 +1037,7 @@ def _apply_Create(self, change): def _apply_Update(self, change): zone = change.new.zone - zone_id = self.zones[zone.name] + zone_id = self.zones[zone.name]['id'] hostname = zone.hostname_from_fqdn(change.new.fqdn[:-1]) _type = change.new._type @@ -1166,7 +1177,9 @@ def _apply_Delete(self, change): parsed_uri = urlsplit(uri) record_name = parsed_uri.netloc record_type = 'URLFWD' - zone_id = self.zones.get(existing.zone.name, False) + zone_id = self.zones.get(existing.zone.name, {}).get( + 'id', False + ) if ( existing_name == record_name and existing_type == record_type @@ -1184,6 +1197,31 @@ def _apply_Delete(self, change): ) self._try_request('DELETE', path) + def _supported_plans(self, zone_name): + zone_id = self.zones[zone_name]['id'] + path = f'/zones/{zone_id}/available_plans' + resp = self._try_request('GET', path) + result = [plan['legacy_id'] for plan in resp['result']] + if result == []: + msg = f'{self.id}: unable to determine supported plans, do you have an Enterprise account?' + raise SupportsException(msg) + return result + + def _update_plan(self, zone_name): + if self.zones[zone_name]['plan'] == self.plan_type: + return + if self.plan_type in self._supported_plans(zone_name): + zone_id = self.zones[zone_name]['id'] + data = {'plan': {'legacy_id': self.plan_type}} + resp = self._try_request('PATCH', f'/zones/{zone_id}', data=data) + # Update the cached plan information + self.zones[zone_name]['plan'] = resp['result']['plan']['legacy_id'] + else: + msg = ( + f'{self.id}: {self.plan_type} is not supported for {zone_name}' + ) + raise SupportsException(msg) + def _apply(self, plan): desired = plan.desired changes = plan.changes @@ -1197,10 +1235,16 @@ def _apply(self, plan): data = {'name': name[:-1], 'jump_start': False} if self.account_id is not None: data['account'] = {'id': self.account_id} + if self.plan_type is not None: + data['plan'] = {'legacy_id': self.plan_type} resp = self._try_request('POST', '/zones', data=data) - zone_id = resp['result']['id'] - self.zones[name] = zone_id + self.zones[name] = {'id': resp['result']['id']} + if self.plan_type is not None: + self.zones[name]['plan'] = resp['result']['plan']['legacy_id'] self._zone_records[name] = {} + elif self.plan_type is not None: + self.log.debug('_apply: updating plan to %s', self.plan_type) + self._update_plan(name) # Force the operation order to be Delete() -> Create() -> Update() # This will help avoid problems in updating a CNAME record into an diff --git a/tests/test_octodns_provider_cloudflare.py b/tests/test_octodns_provider_cloudflare.py index 5a90358..4b8965a 100644 --- a/tests/test_octodns_provider_cloudflare.py +++ b/tests/test_octodns_provider_cloudflare.py @@ -1005,7 +1005,7 @@ def test_pagerules(self): # Set things up to preexist/mock as necessary zone = Zone('unit.tests.', []) # Stuff a fake zone id in place - provider._zones = {zone.name: '42'} + provider._zones = {zone.name: {'id': '42'}} provider._request = Mock() side_effect = [ { @@ -2891,3 +2891,313 @@ def test_process_desired_zone(self): msg = str(ctx.exception) self.assertTrue('subber.unit.tests.' in msg) self.assertTrue('coresponding NS record' in msg) + + def test_plan_handling(self): + provider = CloudflareProvider( + 'test', 'email', 'token', 'account_id', plan_type='enterprise' + ) + provider.pagerules = False # disable pagerules to avoid extra requests + + # Mock the _try_request method + provider._try_request = Mock() + + # Test 1: Creating a new zone with plan + provider._try_request.side_effect = [ + { + # GET /zones response (empty) + 'result': [], + 'result_info': {'count': 0, 'per_page': 50}, + }, + { + # POST /zones response + 'result': { + 'id': '42', + 'name': 'unit.tests', + 'plan': {'legacy_id': 'enterprise'}, + } + }, + { + # POST /zones/42/dns_records response + 'result': { + 'id': 'record-id', + 'type': 'A', + 'name': 'new.unit.tests', + 'content': '1.2.3.4', + 'ttl': 300, + } + }, + ] + + zone = Zone('unit.tests.', []) + zone.add_record( + Record.new( + zone, 'new', {'ttl': 300, 'type': 'A', 'value': '1.2.3.4'} + ) + ) + + plan = provider.plan(zone) + provider.apply(plan) + + # Verify zone creation included plan data + provider._try_request.assert_has_calls( + [ + call( + 'GET', + '/zones', + params={ + 'page': 1, + 'per_page': 50, + 'account.id': 'account_id', + }, + ), + call( + 'POST', + '/zones', + data={ + 'name': 'unit.tests', + 'jump_start': False, + 'account': {'id': 'account_id'}, + 'plan': {'legacy_id': 'enterprise'}, + }, + ), + call( + 'POST', + '/zones/42/dns_records', + data={ + 'content': '1.2.3.4', + 'name': 'new.unit.tests', + 'type': 'A', + 'ttl': 300, + 'proxied': False, + }, + ), + ] + ) + + # Test 2: Updating existing zone's plan + provider._zones = None # Clear cache + provider._try_request.reset_mock() + provider._try_request.side_effect = [ + { + # GET /zones response + 'result': [ + { + 'id': '42', + 'name': 'unit.tests', + 'plan': {'legacy_id': 'pro'}, + } + ], + 'result_info': {'count': 1, 'per_page': 50}, + }, + { + # GET /zones/42/dns_records response + 'result': [], + 'result_info': {'count': 0, 'per_page': 100}, + }, + { + # GET /zones/42/available_plans + 'result': [{'legacy_id': 'pro'}, {'legacy_id': 'enterprise'}] + }, + { + # PATCH /zones/42 (plan update) + 'result': { + 'id': '42', + 'name': 'unit.tests', + 'plan': {'legacy_id': 'enterprise'}, + } + }, + { + # POST /zones/42/dns_records + 'result': { + 'id': 'record-id', + 'type': 'A', + 'name': 'existing.unit.tests', + 'content': '1.2.3.4', + 'ttl': 300, + } + }, + ] + + zone = Zone('unit.tests.', []) + zone.add_record( + Record.new( + zone, 'existing', {'ttl': 300, 'type': 'A', 'value': '1.2.3.4'} + ) + ) + + plan = provider.plan(zone) + provider.apply(plan) + + # Verify plan update calls + provider._try_request.assert_has_calls( + [ + # Get zones to populate cache + call( + 'GET', + '/zones', + params={ + 'page': 1, + 'per_page': 50, + 'account.id': 'account_id', + }, + ), + # Get existing records + call( + 'GET', + '/zones/42/dns_records', + params={'page': 1, 'per_page': 100}, + ), + # Then check available plans + call('GET', '/zones/42/available_plans'), + # Update the plan + call( + 'PATCH', + '/zones/42', + data={'plan': {'legacy_id': 'enterprise'}}, + ), + # Create the new record + call( + 'POST', + '/zones/42/dns_records', + data={ + 'content': '1.2.3.4', + 'name': 'existing.unit.tests', + 'type': 'A', + 'ttl': 300, + 'proxied': False, + }, + ), + ] + ) + + # Test 3: No plan update needed when current plan matches desired plan + provider._zones = None # Clear cache + provider._try_request.reset_mock() + provider._try_request.side_effect = [ + { + # GET /zones response + 'result': [ + { + 'id': '42', + 'name': 'unit.tests', + 'plan': {'legacy_id': 'enterprise'}, + } + ], + 'result_info': {'count': 1, 'per_page': 50}, + }, + { + # GET /zones/42/dns_records response + 'result': [], + 'result_info': {'count': 0, 'per_page': 100}, + }, + { + # POST /zones/42/dns_records + 'result': { + 'id': 'record-id', + 'type': 'A', + 'name': 'existing.unit.tests', + 'content': '1.2.3.4', + 'ttl': 300, + } + }, + ] + + plan = provider.plan(zone) + provider.apply(plan) + + # Verify only zones list was fetched, no other calls needed + provider._try_request.assert_has_calls( + [ + call( + 'GET', + '/zones', + params={ + 'page': 1, + 'per_page': 50, + 'account.id': 'account_id', + }, + ), + call( + 'GET', + '/zones/42/dns_records', + params={'page': 1, 'per_page': 100}, + ), + call( + 'POST', + '/zones/42/dns_records', + data={ + 'content': '1.2.3.4', + 'name': 'existing.unit.tests', + 'type': 'A', + 'ttl': 300, + 'proxied': False, + }, + ), + ] + ) + + # Test 4: Plan update fails when available plans can't be determined + provider._zones = None # Clear cache + provider._try_request.reset_mock() + provider._try_request.side_effect = [ + { + # GET /zones response + 'result': [ + { + 'id': '42', + 'name': 'unit.tests', + 'plan': {'legacy_id': 'pro'}, + } + ], + 'result_info': {'count': 1, 'per_page': 50}, + }, + { + # GET /zones/42/dns_records response + 'result': [], + 'result_info': {'count': 0, 'per_page': 100}, + }, + { + # GET /zones/42/available_plans returns no plans + 'result': [], + 'result_info': {'count': 0, 'per_page': 100}, + }, + ] + + with self.assertRaises(SupportsException) as ctx: + plan = provider.plan(zone) + provider.apply(plan) + + self.assertEqual( + 'test: unable to determine supported plans, do you have an Enterprise account?', + str(ctx.exception), + ) + + # Test 5: Plan update fails when desired plan isn't available + provider._zones = None # Clear cache + provider._try_request.reset_mock() + provider._try_request.side_effect = [ + { + # GET /zones response + 'result': [ + { + 'id': '42', + 'name': 'unit.tests', + 'plan': {'legacy_id': 'pro'}, + } + ], + 'result_info': {'count': 1, 'per_page': 50}, + }, + { + # GET /zones/42/available_plans returns only pro plan + 'result': [{'legacy_id': 'pro'}] + }, + ] + + with self.assertRaises(SupportsException) as ctx: + plan = provider.plan(zone) + provider.apply(plan) + + self.assertEqual( + 'test: enterprise is not supported for unit.tests.', + str(ctx.exception), + )