Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support setting a plan #123

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(
oikarinen marked this conversation as resolved.
Show resolved Hide resolved
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