#
#
#
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 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")