Source code for octodns.provider.base

#
#
#

from ..source.base import BaseSource
from ..zone import Zone
from . import SupportsException
from .plan import Plan


[docs] class BaseProvider(BaseSource):
[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, ): super().__init__(id) self.log.debug( "__init__: id=%s, apply_disabled=%s, " "update_pcent_threshold=%.2f, " "delete_pcent_threshold=%.2f", id, apply_disabled, update_pcent_threshold, delete_pcent_threshold, ) self.apply_disabled = apply_disabled self.update_pcent_threshold = update_pcent_threshold self.delete_pcent_threshold = delete_pcent_threshold self.strict_supports = strict_supports
[docs] def _process_desired_zone(self, desired): """ An opportunity for providers to modify the desired zone records before planning. `desired` is a "shallow" copy, see `Zone.copy` for more information - Must call `super` at an appropriate point for their work, generally that means 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 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: 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" self.supports_warn_or_except(msg, fallback) desired.remove_record(record) return desired
[docs] def _process_existing_zone(self, existing, desired): """ An opportunity for providers to modify the existing zone records before planning. `existing` is a "shallow" copy, see `Zone.copy` for more information - `desired` must not be modified in anyway, it is only for reference - Must call `super` at an appropriate point for their work, generally that means 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`. - May call `Zone.remove_record` to remove records from `existing`. - Must call 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 ): 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): """ An opportunity for providers to filter out false positives due to peculiarities in their implementation. E.g. minimum TTLs. """ return True
[docs] def _extra_changes(self, existing, desired, changes): """ An opportunity for providers to add extra changes to the plan that are necessary to update ancillary record data or configure the zone. E.g. base NS records. """ return []
[docs] def _plan_meta(self, existing, desired, changes): """ An opportunity for providers to indicate they have "meta" changes to the zone which are unrelated to records. Examples may include service plan changes, replication settings, and notes. The returned data is arbitrary/opaque to octoDNS, with the only requirement being that pprint.pformat can display it. A dict is recommended. """ return None
[docs] def supports_warn_or_except(self, msg, fallback): if self.strict_supports: raise SupportsException(f"{self.id}: {msg}") self.log.warning("%s; %s", msg, fallback)
[docs] def plan(self, desired, processors=[]): 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) for processor in processors: existing = processor.process_target_zone(existing, target=self) for processor in processors: 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): """ Submits actual planned changes to the provider. Returns the number of changes made """ 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): raise NotImplementedError("Abstract base class, _apply method missing")