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 Jan 31, 2025
1 parent 0bb7430 commit 95fb5e7
Show file tree
Hide file tree
Showing 5 changed files with 574 additions and 10 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
95 changes: 87 additions & 8 deletions octodns_cloudflare/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from octodns.provider.base import BaseProvider
from octodns.record import Create, Record, Update

from octodns_cloudflare.record import CloudflareZoneRecord

try: # pragma: no cover
from octodns.record.https import HttpsValue
from octodns.record.svcb import SvcbValue
Expand Down Expand Up @@ -89,6 +91,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 @@ -100,11 +103,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 @@ -123,6 +127,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 @@ -214,7 +219,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 @@ -468,7 +481,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 @@ -1016,7 +1029,11 @@ def _gen_key(self, data):

def _apply_Create(self, change):
new = change.new
zone_id = self.zones[new.zone.name]
if new._type == 'CF_ZONE':
self._update_plan(new.zone.name, new.value['plan'])
return

zone_id = self.zones[new.zone.name]['id']
if new._type == 'URLFWD':
path = f'/zones/{zone_id}/pagerules'
else:
Expand All @@ -1026,7 +1043,12 @@ def _apply_Create(self, change):

def _apply_Update(self, change):
zone = change.new.zone
zone_id = self.zones[zone.name]
if change.new._type == 'CF_ZONE':
self._update_plan(zone.name, change.new.value['plan'])
return

zone_id = self.zones[zone.name]['id']

hostname = zone.hostname_from_fqdn(change.new.fqdn[:-1])
_type = change.new._type

Expand Down Expand Up @@ -1156,6 +1178,12 @@ def _apply_Update(self, change):

def _apply_Delete(self, change):
existing = change.existing
if existing._type == 'CF_ZONE':
# Cloudflare plan record deletion is interpreted as a change to the free plan
self._update_plan(
existing.zone.name, CloudflareZoneRecord.FREE_PLAN
)
return
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
Expand All @@ -1166,7 +1194,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
Expand All @@ -1184,6 +1214,32 @@ 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)
try:
result = resp['result']
if isinstance(result, list):
return [plan['legacy_id'] for plan in result]
except KeyError:
pass
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):
if self.zones[zone_name]['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 _apply(self, plan):
desired = plan.desired
changes = plan.changes
Expand All @@ -1197,9 +1253,12 @@ 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] = {}

# Force the operation order to be Delete() -> Create() -> Update()
Expand All @@ -1220,6 +1279,26 @@ def _extra_changes(self, existing, desired, changes):
existing_records = {r: r for r in existing.records}
changed_records = {c.record for c in changes}

# Check if plan needs to be updated
desired_plan = self.plan_type
if desired_plan is not None:
zone_name = desired.name
if zone_name in self.zones:
current_plan = self.zones[zone_name]['plan']
if current_plan != desired_plan:
# Add a fake record with custom type to trigger the plan update
record = Record.new(
desired,
'_plan_update',
{
'type': 'CF_ZONE', # Custom type for Cloudflare zone updates
'ttl': 300,
'value': {'plan': self.plan_type},
},
)
extra_changes.append(Update(record, record))

# Check for other changes (proxied status, auto-ttl, comments, tags)
for desired_record in desired.records:
existing_record = existing_records.get(desired_record, None)
if not existing_record: # Will be created
Expand Down
47 changes: 47 additions & 0 deletions octodns_cloudflare/record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from octodns.record import Record


class CloudflareZoneRecord(Record):
"""
Custom record type for Cloudflare zone.
Supports updating the zone plan.
"""

_type = 'CF_ZONE'
_value_type = dict

FREE_PLAN = 'free'

def __init__(self, zone, name, data, *args, **kwargs):
super().__init__(zone, name, data, *args, **kwargs)
self.value = data['value']

@classmethod
def validate(cls, _name, fqdn, data):
value = data['value']
if not isinstance(value, dict):
return [f'CF_ZONE value must be a dict, not {type(value)}']

if 'plan' not in value:
return ['CF_ZONE value must include "plan" key']

if not all(isinstance(v, str) for v in value.values()):
return ['CF_ZONE values must be strings']

return []

def _equality_tuple(self):
return (self.zone.name, self._type, self.name, self.value['plan'])

def changes(self, other, target):
if not isinstance(other, CloudflareZoneRecord):
return True
return other.value != self.value

def __repr__(self):
return f'CloudflareZoneRecord<{self.value}>'


# Register the custom record type
Record.register_type(CloudflareZoneRecord)
Loading

0 comments on commit 95fb5e7

Please sign in to comment.