#
#
#
from ..deprecation import deprecated
from ..source.base import BaseSource
from ..zone import Zone
from . import SupportsException
from .plan import Plan
[docs]
class BaseProvider(BaseSource):
'''
Base class for all octoDNS providers.
Providers extend :class:`octodns.source.base.BaseSource` to add the ability
to apply DNS changes to a target system. While sources only need to
implement ``populate()`` to read DNS data, providers also implement
``plan()`` and ``apply()`` to manage the complete sync workflow.
The provider workflow:
1. **Populate**: Load current state from the provider via ``populate()``
2. **Process**: Modify zones through ``_process_desired_zone()`` and
``_process_existing_zone()`` to handle provider-specific limitations
3. **Plan**: Compute changes between desired and existing state via ``plan()``
4. **Apply**: Submit approved changes to the provider via ``apply()``
Subclasses must implement:
- **_apply(plan)**: Actually submit changes to the provider's API/backend
Subclasses should override as needed:
- **_process_desired_zone(desired)**: Modify desired state before planning
- **_process_existing_zone(existing, desired)**: Modify existing state before planning
- **_include_change(change)**: Filter out false positive changes
- **_extra_changes(existing, desired, changes)**: Add provider-specific changes
- **_plan_meta(existing, desired, changes)**: Add metadata to the plan
Example provider configuration::
providers:
route53:
class: octodns_route53.Route53Provider
access_key_id: env/AWS_ACCESS_KEY_ID
secret_access_key: env/AWS_SECRET_ACCESS_KEY
Environment variables support default values that are used when the
variable is not set::
providers:
example:
class: whatever.ExampleProvider
region: env/AWS_REGION/us-east-1
zones:
example.com.:
sources:
- config
targets:
- route53
See Also:
- :class:`octodns.source.base.BaseSource`
- :class:`octodns.provider.plan.Plan`
- :class:`octodns.provider.yaml.YamlProvider`
- :doc:`/zone_lifecycle`
'''
[docs]
def __init__(
self,
id,
apply_disabled=False,
update_pcent_threshold=Plan.MAX_SAFE_UPDATE_PCENT,
delete_pcent_threshold=Plan.MAX_SAFE_DELETE_PCENT,
strict_supports=True,
root_ns_warnings=True,
):
'''
Initialize the provider.
:param id: Unique identifier for this provider instance.
:type id: str
:param apply_disabled: If True, the provider will plan changes but not
apply them. Useful for read-only/validation mode.
:type apply_disabled: bool
:param update_pcent_threshold: Maximum percentage of existing records
that can be updated in one sync before
requiring ``--force``. Default: 0.3 (30%).
:type update_pcent_threshold: float
:param delete_pcent_threshold: Maximum percentage of existing records
that can be deleted in one sync before
requiring ``--force``. Default: 0.3 (30%).
:type delete_pcent_threshold: float
:param strict_supports: If True, raise exceptions when unsupported
features are encountered. If False, log warnings
and attempt to work around limitations.
:type strict_supports: bool
:param root_ns_warnings: If True, log warnings about root NS record
handling. If False, silently handle root NS.
:type root_ns_warnings: bool
'''
super().__init__(id)
self.log.debug(
'__init__: id=%s, apply_disabled=%s, '
'update_pcent_threshold=%.2f, '
'delete_pcent_threshold=%.2f, '
'strict_supports=%s, '
'root_ns_warnings=%s',
id,
apply_disabled,
update_pcent_threshold,
delete_pcent_threshold,
strict_supports,
root_ns_warnings,
)
self.apply_disabled = apply_disabled
self.update_pcent_threshold = update_pcent_threshold
self.delete_pcent_threshold = delete_pcent_threshold
self.strict_supports = strict_supports
self.root_ns_warnings = root_ns_warnings
[docs]
def _process_desired_zone(self, desired):
'''
Process the desired zone before planning.
Called during the planning phase to modify the desired zone records
before changes are computed. This is where providers handle their
limitations by removing or modifying records that aren't supported. The
parent method will deal with "standard" unsupported cases like types,
dynamic, and root NS handling. The ``desired`` zone is a shallow copy
(see :meth:`octodns.zone.Zone.copy`).
:param desired: The desired zone state to be processed. This is a shallow
copy that can be modified.
:type desired: octodns.zone.Zone
:return: The processed desired zone, typically the same object passed in.
:rtype: octodns.zone.Zone
.. important::
- Must call ``super()`` at an appropriate point, generally as the
final step of the method, returning the result of the super call.
- May modify ``desired`` directly.
- Must not modify records directly; ``record.copy()`` should be called,
the results of which can be modified, and then ``Zone.add_record()``
may be used with ``replace=True``.
- May call ``Zone.remove_record()`` to remove records from ``desired``.
- Must call :meth:`supports_warn_or_except` with information about any
changes that are made to have them logged or throw errors depending
on the provider configuration.
'''
for record in desired.records:
if not self.supports(record):
msg = f'{record._type} records not supported for {record.fqdn}'
fallback = 'omitting record'
self.supports_warn_or_except(msg, fallback)
desired.remove_record(record)
elif getattr(record, 'dynamic', False):
if self.SUPPORTS_DYNAMIC:
if not self.SUPPORTS_POOL_VALUE_STATUS:
# drop unsupported status flag
unsupported_pools = []
for _id, pool in record.dynamic.pools.items():
for value in pool.data['values']:
if value['status'] != 'obey':
unsupported_pools.append(_id)
if unsupported_pools:
unsupported_pools = ','.join(unsupported_pools)
msg = (
f'"status" flag used in pools {unsupported_pools}'
f' in {record.fqdn} is not supported'
)
fallback = (
'will ignore it and respect the healthcheck'
)
self.supports_warn_or_except(msg, fallback)
record = record.copy()
for pool in record.dynamic.pools.values():
for value in pool.data['values']:
value['status'] = 'obey'
desired.add_record(record, replace=True)
if not self.SUPPORTS_DYNAMIC_SUBNETS:
subnet_rules = []
for i, rule in enumerate(record.dynamic.rules):
rule = rule.data
if not rule.get('subnets'):
continue
msg = f'rule {i + 1} contains unsupported subnet matching in {record.fqdn}'
if rule.get('geos'):
fallback = 'using geos only'
self.supports_warn_or_except(msg, fallback)
else:
fallback = 'skipping the rule'
self.supports_warn_or_except(msg, fallback)
subnet_rules.append(i)
if subnet_rules:
record = record.copy()
rules = record.dynamic.rules
# drop subnet rules in reverse order so indices don't shift during rule deletion
for i in sorted(subnet_rules, reverse=True):
rule = rules[i].data
if rule.get('geos'):
del rule['subnets']
else:
del rules[i]
# drop any pools rendered unused
pools = record.dynamic.pools
pools_seen = set()
for rule in record.dynamic.rules:
pool = rule.data['pool']
while pool:
pools_seen.add(pool)
pool = pools[pool].data.get('fallback')
pools_unseen = set(pools.keys()) - pools_seen
for pool in pools_unseen:
self.log.warning(
'%s: skipping pool %s which is rendered unused due to lack of support for subnet targeting',
record.fqdn,
pool,
)
del pools[pool]
desired.add_record(record, replace=True)
else:
msg = f'dynamic records not supported for {record.fqdn}'
fallback = 'falling back to simple record'
self.supports_warn_or_except(msg, fallback)
record = record.copy()
record.dynamic = None
desired.add_record(record, replace=True)
elif (
record._type == 'PTR'
and len(record.values) > 1
and not self.SUPPORTS_MULTIVALUE_PTR
):
# replace with a single-value copy
msg = f'multi-value PTR records not supported for {record.fqdn}'
fallback = f'falling back to single value, {record.value}'
self.supports_warn_or_except(msg, fallback)
record = record.copy()
record.values = [record.value]
desired.add_record(record, replace=True)
record = desired.root_ns
if self.SUPPORTS_ROOT_NS:
if not record and self.root_ns_warnings:
self.log.warning(
'root NS record supported, but no record '
'is configured for %s',
desired.decoded_name,
)
else:
if record:
# we can't manage root NS records, get rid of it
msg = f'root NS record not supported for {record.fqdn}'
fallback = 'ignoring it'
if self.strict_supports:
raise SupportsException(f'{self.id}: {msg}')
if self.root_ns_warnings:
self.log.warning('%s; %s', msg, fallback)
desired.remove_record(record)
return desired
[docs]
def _process_existing_zone(self, existing, desired, lenient=False):
'''
Process the existing zone before planning.
Called during the planning phase to modify the existing zone records
before changes are computed. This allows providers to normalize or filter
the current state from the provider. The ``existing`` zone is a shallow
copy (see :meth:`octodns.zone.Zone.copy`).
:param existing: The existing zone state from the provider. This is a
shallow copy that can be modified.
:type existing: octodns.zone.Zone
:param desired: The desired zone state. This is for reference only and
must not be modified.
:type desired: octodns.zone.Zone
:param lenient: When True, relaxed validation rules should be applied
when modifying zone records (e.g. passed to
``Zone.add_record()``).
:type lenient: bool
:return: The processed existing zone, typically the same object passed in.
:rtype: octodns.zone.Zone
.. important::
- ``desired`` must not be modified in any way; it is only for reference.
- Must call ``super()`` at an appropriate point, generally as the
final step of the method, returning the result of the super call.
- May modify ``existing`` directly.
- Must not modify records directly; ``record.copy()`` should be called,
the results of which can be modified, and then ``Zone.add_record()``
may be used with ``replace=True`` and ``lenient=lenient``.
- May call ``Zone.remove_record()`` to remove records from ``existing``.
- Must call :meth:`supports_warn_or_except` with information about any
changes that are made to have them logged or throw errors depending
on the provider configuration.
'''
existing_root_ns = existing.root_ns
if existing_root_ns and (
not self.SUPPORTS_ROOT_NS or not desired.root_ns
):
if self.root_ns_warnings:
self.log.info(
'root NS record in existing, but not supported or '
'not configured; ignoring it'
)
existing.remove_record(existing_root_ns)
return existing
[docs]
def _include_change(self, change):
'''
Filter out false positive changes.
Called during planning to allow providers to filter out changes that
are false positives due to peculiarities in their implementation (e.g.,
providers that enforce minimum TTLs).
:param change: A change being considered for inclusion in the plan.
:type change: octodns.record.change.Change
:return: True if the change should be included in the plan, False to
filter it out.
:rtype: bool
'''
return True
[docs]
def supports_warn_or_except(self, msg, fallback):
'''
Handle unsupported features based on strict_supports setting.
If ``strict_supports`` is True, raises a :class:`SupportsException`.
Otherwise, logs a warning with the message and fallback behavior.
:param msg: Description of the unsupported feature or limitation.
:type msg: str
:param fallback: Description of the fallback behavior being used.
:type fallback: str
:raises SupportsException: If ``strict_supports`` is True.
'''
if self.strict_supports:
raise SupportsException(f'{self.id}: {msg}')
self.log.warning('%s; %s', msg, fallback)
[docs]
def plan(self, desired, processors=[], lenient=False):
'''
Compute a plan of changes needed to sync the desired state to this provider.
This is the main planning method that orchestrates the entire planning
workflow. It populates the current state, processes both desired and
existing zones, runs processors, computes changes, and returns a
:class:`Plan` object.
The planning workflow:
1. Populate existing state from the provider via :meth:`populate`
2. Process desired zone via :meth:`_process_desired_zone`
3. Process existing zone via :meth:`_process_existing_zone`
4. Run target zone processors
5. Run source and target zone processors
6. Compute changes between existing and desired
7. Filter changes via :meth:`_include_change`
8. Add extra changes via :meth:`_extra_changes`
9. Add metadata via :meth:`_plan_meta`
10. Create and return a Plan (or None if no changes)
:param desired: The desired zone state to sync to this provider.
:type desired: octodns.zone.Zone
:param processors: List of processors to run during planning.
:type processors: list[octodns.processor.base.BaseProcessor]
:param lenient: When True, relaxed validation rules should be applied
by processors when modifying zone records.
:type lenient: bool
:return: A Plan containing the computed changes, or None if no changes
are needed.
:rtype: octodns.provider.plan.Plan or None
See Also:
- :doc:`/zone_lifecycle` for details on the complete sync workflow
'''
self.log.info('plan: desired=%s', desired.decoded_name)
existing = Zone(desired.name, desired.sub_zones)
exists = self.populate(existing, target=True, lenient=True)
if exists is None:
# If your code gets this warning see Source.populate for more
# information
self.log.warning(
'Provider %s used in target mode did not return exists', self.id
)
# Make a (shallow) copy of the desired state so that everything from
# now on (in this target) can modify it as they see fit without
# worrying about impacting other targets.
desired = desired.copy()
desired = self._process_desired_zone(desired)
existing = self._process_existing_zone(
existing, desired, lenient=lenient
)
for processor in processors:
try:
existing = processor.process_target_zone(
existing, target=self, lenient=lenient
)
except TypeError as e:
if "unexpected keyword argument 'lenient'" not in str(e):
raise
deprecated(
f'`process_target_zone` method does not support the `lenient` param, fallback is DEPRECATED. Will be removed in 2.0. Class {processor.__class__.__name__}',
stacklevel=99,
)
existing = processor.process_target_zone(existing, target=self)
for processor in processors:
try:
desired, existing = processor.process_source_and_target_zones(
desired, existing, self, lenient=lenient
)
except TypeError as e:
if "unexpected keyword argument 'lenient'" not in str(e):
raise
deprecated(
f'`process_source_and_target_zones` method does not support the `lenient` param, fallback is DEPRECATED. Will be removed in 2.0. Class {processor.__class__.__name__}',
stacklevel=99,
)
desired, existing = processor.process_source_and_target_zones(
desired, existing, self
)
# compute the changes at the zone/record level
changes = existing.changes(desired, self)
# allow the provider to filter out false positives
before = len(changes)
changes = [c for c in changes if self._include_change(c)]
after = len(changes)
if before != after:
self.log.info('plan: filtered out %s changes', before - after)
# allow the provider to add extra changes it needs
extra = self._extra_changes(
existing=existing, desired=desired, changes=changes
)
if extra:
self.log.info(
'plan: extra changes\n %s',
'\n '.join([str(c) for c in extra]),
)
changes += extra
meta = self._plan_meta(
existing=existing, desired=desired, changes=changes
)
if changes or meta:
plan = Plan(
existing,
desired,
changes,
exists,
update_pcent_threshold=self.update_pcent_threshold,
delete_pcent_threshold=self.delete_pcent_threshold,
meta=meta,
)
self.log.info('plan: %s', plan)
return plan
self.log.info('plan: No changes')
return None
[docs]
def apply(self, plan):
'''
Apply the planned changes to the provider.
This is the main apply method that submits the approved plan to the
provider's backend. If ``apply_disabled`` is True, this method does
nothing and returns 0.
:param plan: The plan containing changes to apply.
:type plan: octodns.provider.plan.Plan
:return: The number of changes that were applied.
:rtype: int
See Also:
- :meth:`_apply` for the provider-specific implementation
- :doc:`/zone_lifecycle` for details on the complete sync workflow
'''
if self.apply_disabled:
self.log.info('apply: disabled')
return 0
zone_name = plan.desired.decoded_name
num_changes = len(plan.changes)
self.log.info('apply: making %d changes to %s', num_changes, zone_name)
self._apply(plan)
return len(plan.changes)
[docs]
def _apply(self, plan):
'''
Actually submit the changes to the provider's backend.
This is an abstract method that must be implemented by all provider
subclasses. It should take the changes in the plan and apply them to
the provider's API or backend system.
:param plan: The plan containing changes to apply.
:type plan: octodns.provider.plan.Plan
:raises NotImplementedError: This base class method must be overridden
by subclasses.
.. important::
- Must implement the actual logic to submit changes to the provider.
- Should handle errors appropriately (log, raise exceptions, etc.).
- May apply changes in any order that makes sense for the provider
with as much safety as possible given the API methods available.
Often the order of changes should apply deletes before adds to
avoid comflicts during type changes, specidically **CNAME** <->
other types. If the provider's API supports batching or atomic
changes they should be used.
- Should be idempotent where possible.
'''
raise NotImplementedError('Abstract base class, _apply method missing')