Skip to content

Commit

Permalink
feat: support setting a plan
Browse files Browse the repository at this point in the history
  • Loading branch information
oikarinen committed Feb 3, 2025
1 parent cc23158 commit 37fb2fe
Show file tree
Hide file tree
Showing 3 changed files with 387 additions and 14 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 79 additions & 13 deletions octodns_cloudflare/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@
# TODO: remove __VERSION__ with the next major version release
__version__ = __VERSION__ = '0.0.7'

CLOUDFLARE_FREE_PLAN = 'free'


class CloudflareError(ProviderException):

def __init__(self, data):
try:
message = data['errors'][0]['message']
Expand All @@ -38,16 +41,19 @@ def __init__(self, data):


class CloudflareAuthenticationError(CloudflareError):

def __init__(self, data):
CloudflareError.__init__(self, data)


class CloudflareRateLimitError(CloudflareError):

def __init__(self, data):
CloudflareError.__init__(self, data)


class Cloudflare5xxError(CloudflareError):

def __init__(self, data):
CloudflareError.__init__(self, data)

Expand Down Expand Up @@ -94,6 +100,7 @@ def __init__(
account_id=None,
cdn=False,
pagerules=True,
plan_type=None,
retry_count=4,
retry_period=300,
auth_error_retry_count=0,
Expand All @@ -105,11 +112,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)

Expand All @@ -128,6 +136,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
Expand Down Expand Up @@ -231,7 +240,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

Expand Down Expand Up @@ -485,7 +502,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 []

Expand Down Expand Up @@ -1033,7 +1050,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:
Expand All @@ -1043,7 +1060,8 @@ 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

Expand Down Expand Up @@ -1176,7 +1194,7 @@ def _apply_Delete(self, change):
existing_name = existing.fqdn[:-1]
# Make sure to map ALIAS to CNAME when looking for the target to delete
existing_type = 'CNAME' if existing._type == 'ALIAS' else existing._type
zone_id = self.zones[existing.zone.name]
zone_id = self.zones[existing.zone.name]['id']
for record in self.zone_records(existing.zone):
if 'targets' in record and self.pagerules:
uri = record['targets'][0]['constraint']['value']
Expand Down Expand Up @@ -1209,23 +1227,71 @@ 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 = resp['result']
if isinstance(result, list):
return [plan['legacy_id'] for plan in result]
msg = f'{self.id}: unable to determine supported plans, do you have an Enterprise account?'
raise SupportsException(msg)

def _update_plan(self, zone_name, plan):
current_plan = self.zones[zone_name].get('plan', False)
if current_plan and current_plan == plan:
return
if plan in self._supported_plans(zone_name):
zone_id = self.zones[zone_name]['id']
data = {'plan': {'legacy_id': plan}}
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}: {plan} is not supported for {zone_name}'
raise SupportsException(msg)

def _plan_meta(self, existing, desired, changes):
meta = {}
zone_name = desired.name
desired_plan = self.plan_type
if zone_name in self.zones:
current_plan = self.zones[zone_name].get('plan', False)
if desired_plan is None and current_plan is not False:
# If desired plan is removed and current plan is set, set to free plan
meta['cloudflare_plan'] = CLOUDFLARE_FREE_PLAN
elif current_plan != desired_plan:
meta['cloudflare_plan'] = desired_plan
return meta

def _apply(self, plan):
desired = plan.desired
changes = plan.changes
zone_name = desired.name

self.log.debug(
'_apply: zone=%s, len(changes)=%d', desired.name, len(changes)
'_apply: zone=%s, len(changes)=%d', zone_name, len(changes)
)

name = desired.name
if name not in self.zones:
if zone_name not in self.zones:
self.log.debug('_apply: no matching zone, creating')
data = {'name': name[:-1], 'jump_start': False}
data = {'name': zone_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._zone_records[name] = {}
self.zones[zone_name] = {'id': zone_id}
plan = resp['result'].get('plan', {}).get('legacy_id', False)
if plan is not False:
self.zones[zone_name]['plan'] = plan

# Handle plan changes if needed
# older versions of octodns don't have meta support.
meta = getattr(plan, 'meta', {})
if 'cloudflare_plan' in meta:
self._update_plan(zone_name, meta['cloudflare_plan'])

# Force the operation order to be Delete() -> Create() -> Update()
# This will help avoid problems in updating a CNAME record into an
Expand All @@ -1237,7 +1303,7 @@ def _apply(self, plan):
getattr(self, f'_apply_{class_name}')(change)

# clear the cache
self._zone_records.pop(name, None)
self._zone_records.pop(zone_name, None)

def _extra_changes(self, existing, desired, changes):
extra_changes = []
Expand Down
Loading

0 comments on commit 37fb2fe

Please sign in to comment.