diff --git a/.changelog/3f8fbaa8c6324b649fa64ccbec8f8f5c.md b/.changelog/3f8fbaa8c6324b649fa64ccbec8f8f5c.md new file mode 100644 index 0000000..d0f75ed --- /dev/null +++ b/.changelog/3f8fbaa8c6324b649fa64ccbec8f8f5c.md @@ -0,0 +1,4 @@ +--- +type: none +--- +Add comprehensive API documentation to Zone class and related exceptions diff --git a/.changelog/99fb6721f2d340ed933d7407c91622ca.md b/.changelog/99fb6721f2d340ed933d7407c91622ca.md new file mode 100644 index 0000000..2ba6b0d --- /dev/null +++ b/.changelog/99fb6721f2d340ed933d7407c91622ca.md @@ -0,0 +1,4 @@ +--- +type: none +--- +Pass at adding AI-assisted API documentation \ No newline at end of file diff --git a/octodns/processor/base.py b/octodns/processor/base.py index 277d0af..d2f3d97 100644 --- a/octodns/processor/base.py +++ b/octodns/processor/base.py @@ -4,93 +4,207 @@ class ProcessorException(Exception): + ''' + Exception raised when a processor encounters an error during processing. + + A subclass of this exception can be raised by processors when they + encounter invalid configurations, unsupported operations, or other + processing errors. + ''' + pass class BaseProcessor(object): + ''' + Base class for all octoDNS processors. + + Processors provide hooks into the octoDNS sync process to modify zones and + records at various stages. They can be used to filter, transform, or + validate DNS records before planning and applying changes. + + Processors are executed in the order they are configured and can modify: + + - **Source zones** (after sources populate, before planning) + - **Target zones** (after target populates, before planning) + - **Source and target zones** (just before computing changes) + - **Plans** (after planning, before applying) + + Subclasses should override one or more of the ``process_*`` methods to + implement custom processing logic. + + Example usage:: + + processors: + my-processor: + class: my.custom.processor.MyProcessor + # processor-specific configuration + + zones: + example.com.: + sources: + - config + processors: + - my-processor + targets: + - route53 + + See Also: + - :class:`octodns.processor.filter.TypeAllowlistFilter` + - :class:`octodns.processor.ownership.OwnershipProcessor` + - :class:`octodns.processor.acme.AcmeMangingProcessor` + ''' + def __init__(self, name): + ''' + Initialize the processor. + + :param name: Unique identifier for this processor instance. Used in + logging and configuration references. + :type name: str + + .. note:: + The ``name`` parameter is deprecated and will be removed in + version 2.0. Use ``id`` instead. + ''' # TODO: name is DEPRECATED, remove in 2.0 self.id = self.name = name def process_source_zone(self, desired, sources): ''' + Process the desired zone after all sources have populated. + Called after all sources have completed populate. Provides an - opportunity for the processor to modify the desired `Zone` that targets + opportunity for the processor to modify the desired zone that targets will receive. - - Will see `desired` after any modifications done by - `Provider._process_desired_zone` and processors configured to run - before this one. - - May modify `desired` directly. - - Must return `desired` which will normally be the `desired` param. - - 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`. - - Sources may be empty, as will be the case for aliased zones. + :param desired: The desired zone state after all sources have populated. + This zone will be used as the target state for planning. + :type desired: octodns.zone.Zone + :param sources: List of source providers that populated the zone. May be + empty for aliased zones. + :type sources: list[octodns.provider.base.BaseProvider] + + :return: The modified desired zone, typically the same object passed in. + :rtype: octodns.zone.Zone + + .. important:: + - Will see ``desired`` after any modifications done by + ``Provider._process_desired_zone`` and processors configured to run + before this one. + - May modify ``desired`` directly. + - Must return ``desired`` which will normally be the ``desired`` param. + - 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``. + - Sources may be empty, as will be the case for aliased zones. ''' return desired def process_target_zone(self, existing, target): ''' - Called after a target has completed `populate`, before changes are - computed between `existing` and `desired`. This provides an opportunity - to modify the `existing` `Zone`. - - - Will see `existing` after any modifications done by processors - configured to run before this one. - - May modify `existing` directly. - - Must return `existing` which will normally be the `existing` param. - - 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`. + Process the existing zone after the target has populated. + + Called after a target has completed ``populate``, before changes are + computed between ``existing`` and ``desired``. This provides an + opportunity to modify the existing zone state. + + :param existing: The current zone state from the target provider. + :type existing: octodns.zone.Zone + :param target: The target provider that populated the existing zone. + :type target: octodns.provider.base.BaseProvider + + :return: The modified existing zone, typically the same object passed in. + :rtype: octodns.zone.Zone + + .. important:: + - Will see ``existing`` after any modifications done by processors + configured to run before this one. + - May modify ``existing`` directly. + - Must return ``existing`` which will normally be the ``existing`` param. + - 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``. ''' return existing def process_source_and_target_zones(self, desired, existing, target): ''' - Called just prior to computing changes for ``target`` between - ``desired`` and `existing`. Provides an opportunity for the processor - to modify either the desired or existing ``Zone`` that will be used to - compute the changes and create the initial plan. - - - Will see ``desired`` after any modifications done by - ``Provider._process_desired_zone`` and all processors via - ``Processor.process_source_zone`` - - Will see ``existing`` after any modifications done by all processors - via ``Processor.process_target_zone`` - - Will see both ``desired`` and ``existing`` after any modifications - done by any processors configured to run before this one via - ``Processor.process_source_and_target_zones``. - - May modify ``desired`` directly. - - Must return ``desired`` which will normally be the ``desired`` param. - - May modify ``existing`` directly. - - Must return ``existing`` which will normally be the ``existing`` - param. - - 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``. - - May call ``Zone.remove_record`` to remove records from ``existing``. + Process both desired and existing zones before computing changes. + + Called just prior to computing changes for the target provider between + ``desired`` and ``existing``. Provides an opportunity for the processor + to modify either or both zones that will be used to compute the changes + and create the initial plan. + + :param desired: The desired zone state after all source processing. + :type desired: octodns.zone.Zone + :param existing: The existing zone state after all target processing. + :type existing: octodns.zone.Zone + :param target: The target provider for which changes will be computed. + :type target: octodns.provider.base.BaseProvider + + :return: A tuple of (desired, existing) zones, typically the same + objects passed in. + :rtype: tuple[octodns.zone.Zone, octodns.zone.Zone] + + .. important:: + - Will see ``desired`` after any modifications done by + ``Provider._process_desired_zone`` and all processors via + ``Processor.process_source_zone``. + - Will see ``existing`` after any modifications done by all processors + via ``Processor.process_target_zone``. + - Will see both ``desired`` and ``existing`` after any modifications + done by any processors configured to run before this one via + ``Processor.process_source_and_target_zones``. + - May modify ``desired`` directly. + - Must return ``desired`` which will normally be the ``desired`` param. + - May modify ``existing`` directly. + - Must return ``existing`` which will normally be the ``existing`` + param. + - 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``. + - May call ``Zone.remove_record`` to remove records from ``existing``. ''' return desired, existing def process_plan(self, plan, sources, target): ''' + Process the plan after it has been computed. + Called after the planning phase has completed. Provides an opportunity - for the processors to modify the plan thus changing the actions that + for the processor to modify the plan, thus changing the actions that will be displayed and potentially applied. - - `plan` may be None if no changes were detected, if so a `Plan` may - still be created and returned. - - May modify `plan.changes` directly or create a new `Plan`. - - Does not have to modify `plan.desired` and/or `plan.existing` to line - up with any modifications made to `plan.changes`. - - Should copy over `plan.exists`, `plan.update_pcent_threshold`, and - `plan.delete_pcent_threshold` when creating a new `Plan`. - - Must return a `Plan` which may be `plan` or can be a newly created - one `plan.desired` and `plan.existing` copied over as-is or modified. + :param plan: The computed plan containing the changes to be applied. + May be None if no changes were detected. + :type plan: octodns.provider.plan.Plan or None + :param sources: List of source providers for this zone. May be empty + for aliased zones. + :type sources: list[octodns.provider.base.BaseProvider] + :param target: The target provider for which the plan was created. + :type target: octodns.provider.base.BaseProvider + + :return: The modified plan, which may be the same object passed in, + a newly created Plan, or None if no changes are needed. + :rtype: octodns.provider.plan.Plan or None + + .. important:: + - ``plan`` may be None if no changes were detected; if so, a ``Plan`` + may still be created and returned. + - May modify ``plan.changes`` directly or create a new ``Plan``. + - Does not have to modify ``plan.desired`` and/or ``plan.existing`` to + line up with any modifications made to ``plan.changes``. + - Should copy over ``plan.exists``, ``plan.update_pcent_threshold``, + and ``plan.delete_pcent_threshold`` when creating a new ``Plan``. + - Must return a ``Plan`` which may be ``plan`` or can be a newly + created one with ``plan.desired`` and ``plan.existing`` copied over + as-is or modified. + - Sources may be empty, as will be the case for aliased zones. ''' # plan may be None if no changes were detected up until now, the # process may still create a plan. diff --git a/octodns/provider/base.py b/octodns/provider/base.py index b089ffd..8910131 100644 --- a/octodns/provider/base.py +++ b/octodns/provider/base.py @@ -9,6 +9,56 @@ from .plan import Plan 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 + + 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` + ''' + def __init__( self, id, @@ -18,6 +68,30 @@ class BaseProvider(BaseSource): 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, ' @@ -40,21 +114,33 @@ class BaseProvider(BaseSource): 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. + 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: @@ -178,22 +264,35 @@ class BaseProvider(BaseSource): 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. + 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 + + :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``. + - 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 @@ -211,35 +310,116 @@ class BaseProvider(BaseSource): def _include_change(self, change): ''' - An opportunity for providers to filter out false positives due to - peculiarities in their implementation. E.g. minimum TTLs. + 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 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. + Add provider-specific extra changes to the plan. + + Called during planning to allow providers to add extra changes that are + necessary to update ancillary record data or configure the zone (e.g., + base NS records that must be managed separately). + + :param existing: The existing zone state. + :type existing: octodns.zone.Zone + :param desired: The desired zone state. + :type desired: octodns.zone.Zone + :param changes: The list of changes already computed. + :type changes: list[octodns.record.change.Change] + + :return: A list of additional changes to add to the plan. Return an + empty list if no extra changes are needed. + :rtype: list[octodns.record.change.Change] ''' return [] 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. + Indicate provider-specific metadata changes to the zone. + + Called during planning to allow 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. + + :param existing: The existing zone state. + :type existing: octodns.zone.Zone + :param desired: The desired zone state. + :type desired: octodns.zone.Zone + :param changes: The list of changes computed for this plan. + :type changes: list[octodns.record.change.Change] + + :return: Arbitrary metadata about zone-level changes. The only + requirement is that ``pprint.pformat`` can display it. A dict + is recommended. Return None if no meta changes. + :rtype: dict or None ''' return None 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) def plan(self, desired, processors=[]): + ''' + 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] + + :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) @@ -310,8 +490,21 @@ class BaseProvider(BaseSource): def apply(self, plan): ''' - Submits actual planned changes to the provider. Returns the number of - changes made + 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') @@ -324,4 +517,28 @@ class BaseProvider(BaseSource): return len(plan.changes) 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') diff --git a/octodns/source/base.py b/octodns/source/base.py index d790213..40cec09 100644 --- a/octodns/source/base.py +++ b/octodns/source/base.py @@ -4,6 +4,48 @@ class BaseSource(object): + ''' + Base class for all octoDNS sources and providers. + + Sources are responsible for loading DNS records from various backends into + octoDNS zones. They implement the ``populate`` method to read DNS data from + their respective data stores (YAML files, APIs, databases, etc.) and add + records to the provided zone. + + Subclasses must define the following class attributes either statically or + prior to calling ``super().__init__``: + + - **SUPPORTS**: Set of supported record types (e.g., ``{'A', 'AAAA', 'CNAME'}``) + - **SUPPORTS_GEO**: Boolean indicating if the source supports GeoDNS records + - **log**: Logger instance for the source + + Optional class attributes: + + - **SUPPORTS_MULTIVALUE_PTR**: Support for multiple PTR records (default: False) + - **SUPPORTS_POOL_VALUE_STATUS**: Support for pool value status flags (default: False) + - **SUPPORTS_ROOT_NS**: Support for root NS records (default: False) + - **SUPPORTS_DYNAMIC_SUBNETS**: Support for dynamic subnet-based routing (default: False) + + Example usage:: + + sources: + config: + class: octodns.provider.yaml.YamlProvider + directory: ./config + + zones: + example.com.: + sources: + - config + targets: + - route53 + + See Also: + - :class:`octodns.provider.yaml.YamlProvider` + - :class:`octodns.source.tinydns.TinyDnsFileSource` + - :class:`octodns.source.envvar.EnvVarSource` + ''' + SUPPORTS_MULTIVALUE_PTR = False SUPPORTS_POOL_VALUE_STATUS = False SUPPORTS_ROOT_NS = False @@ -11,8 +53,15 @@ class BaseSource(object): def __init__(self, id): ''' - :param id: unique identifier for the provider or source + Initialize the source. + + :param id: Unique identifier for this source instance. Used in logging + and configuration references. :type id: str + + :raises NotImplementedError: If required class attributes (``log``, + ``SUPPORTS_GEO``, or ``SUPPORTS``) are not + defined in the subclass. ''' self.id = id @@ -31,29 +80,75 @@ class BaseSource(object): @property def SUPPORTS_DYNAMIC(self): + ''' + Indicates whether this source supports dynamic records. + + Dynamic records include advanced routing features like GeoDNS pools, + health checks, and weighted responses. Most sources do not support + dynamic records. + + :return: True if dynamic records are supported, False otherwise. + :rtype: bool + ''' return False def populate(self, zone, target=False, lenient=False): ''' - Loads all records the provider knows about for the provided zone + Load DNS records from the source into the provided zone. - When `target` is True the populate call is being made to load the - current state of the provider. + This method is responsible for reading DNS data from the source's + backend and adding records to the zone using ``zone.add_record()``. + Subclasses must implement this method. - When `lenient` is True the populate call may skip record validation and - do a "best effort" load of data. That will allow through some common, - but not best practices stuff that we otherwise would reject. E.g. no - trailing . or missing escapes for ;. + :param zone: The zone to populate with records from this source. + :type zone: octodns.zone.Zone + :param target: If True, the populate call is loading the current state + from a target provider (for comparison during sync). If + False, loading desired state from a source. + :type target: bool + :param lenient: If True, skip strict record validation and do a "best + effort" load of data. This allows some non-best-practice + configurations through (e.g., missing trailing dots or + unescaped semicolons). + :type lenient: bool - When target is True (loading current state) this method should return - True if the zone exists or False if it does not. + :return: When ``target`` is True (loading current state), should return + True if the zone exists in the target or False if it does not. + When ``target`` is False (loading desired state), return value + is ignored and may be None. + :rtype: bool or None + + :raises NotImplementedError: This base class method must be overridden + by subclasses. + + .. important:: + - Must use ``zone.add_record()`` to add records to the zone. + - Should not modify the zone name or other zone properties. + - When ``target=True``, must return a boolean indicating zone existence. + - When ``lenient=True``, should relax validation to handle common + non-standard configurations. ''' raise NotImplementedError( 'Abstract base class, populate method missing' ) def supports(self, record): + ''' + Check if this source supports the given record type. + + :param record: The DNS record to check for support. + :type record: octodns.record.base.Record + + :return: True if the record type is supported, False otherwise. + :rtype: bool + ''' return record._type in self.SUPPORTS def __repr__(self): + ''' + Return a string representation of this source. + + :return: The class name of this source instance. + :rtype: str + ''' return self.__class__.__name__ diff --git a/octodns/zone.py b/octodns/zone.py index 9d975cd..f6bb150 100644 --- a/octodns/zone.py +++ b/octodns/zone.py @@ -12,6 +12,17 @@ from .record import Create, Delete class SubzoneRecordException(Exception): + ''' + Exception raised when a record belongs in a sub-zone but is added to the parent. + + This exception is raised when attempting to add a record to a zone that + should actually be managed in a configured sub-zone. Only NS and DS records + are allowed at the sub-zone boundary. + + :param record: The record that caused the exception. + :type record: octodns.record.base.Record + ''' + def __init__(self, msg, record): self.record = record @@ -22,6 +33,19 @@ class SubzoneRecordException(Exception): class DuplicateRecordException(Exception): + ''' + Exception raised when attempting to add a duplicate record to a zone. + + A duplicate is defined as a record with the same name and type as an + existing record in the zone. The exception includes references to both + the existing and new records for debugging. + + :param existing: The existing record in the zone. + :type existing: octodns.record.base.Record + :param new: The new record being added. + :type new: octodns.record.base.Record + ''' + def __init__(self, msg, existing, new): self.existing = existing self.new = new @@ -44,6 +68,17 @@ class DuplicateRecordException(Exception): class InvalidNodeException(Exception): + ''' + Exception raised when CNAME records coexist with other records at a node. + + Per DNS standards, CNAME records cannot coexist with other record types + at the same node. This exception is raised when such an invalid + configuration is detected. + + :param record: The record that caused the exception. + :type record: octodns.record.base.Record + ''' + def __init__(self, msg, record): self.record = record @@ -54,10 +89,59 @@ class InvalidNodeException(Exception): class InvalidNameError(Exception): + ''' + Exception raised when a zone name is invalid. + + Zone names must: + - End with a dot (.) + - Not contain double dots (..) + - Not contain whitespace + ''' + pass class Zone(object): + ''' + Container for DNS records belonging to a single DNS zone. + + A Zone represents a DNS zone and manages all the records within it. It + provides methods for adding, removing, and querying records, as well as + computing changes between zones and applying those changes. + + Zones support copy-on-write semantics via the :meth:`copy` method, which + creates shallow copies that are hydrated on first modification. This allows + for efficient processing of zones through multiple stages without + unnecessary copying. + + Key features: + + - **Record management**: Add, remove, and query DNS records + - **Validation**: Enforce DNS standards (CNAME restrictions, sub-zone rules) + - **IDNA support**: Handle internationalized domain names + - **Sub-zone awareness**: Respect configured sub-zone boundaries + - **Change tracking**: Compute differences between desired and existing state + - **Copy-on-write**: Efficient shallow copying with lazy hydration + + Example usage:: + + from octodns.zone import Zone + from octodns.record import Record + + zone = Zone('example.com.', []) + record = Record.new(zone, 'www', {'type': 'A', 'ttl': 300, 'value': '1.2.3.4'}) + zone.add_record(record) + + # Create a shallow copy + copy = zone.copy() + # Modifications to copy don't affect the original until hydrated + + See Also: + - :doc:`/zone_lifecycle` for details on zone processing workflow + - :class:`octodns.record.base.Record` + - :class:`octodns.provider.base.BaseProvider` + ''' + log = getLogger('Zone') def __init__( @@ -67,6 +151,31 @@ class Zone(object): update_pcent_threshold=None, delete_pcent_threshold=None, ): + ''' + Initialize a DNS zone. + + :param name: The zone name (must end with a dot). Internationalized + domain names (IDN) are automatically encoded to IDNA format. + :type name: str + :param sub_zones: List of sub-zone names managed separately. Records + belonging to sub-zones will be rejected (except NS/DS + at the boundary). + :type sub_zones: list[str] + :param update_pcent_threshold: Override for maximum update percentage + threshold. If None, uses provider default. + :type update_pcent_threshold: float or None + :param delete_pcent_threshold: Override for maximum delete percentage + threshold. If None, uses provider default. + :type delete_pcent_threshold: float or None + + :raises InvalidNameError: If the zone name is invalid (missing trailing + dot, contains double dots, or has whitespace). + + .. important:: + - Zone names must end with a dot (.) + - Zone names are automatically encoded to IDNA format internally + - Sub-zones prevent records from being added to the parent zone + ''' if not name[-1] == '.': raise InvalidNameError( f'Invalid zone name {name}, missing ending dot' @@ -108,17 +217,53 @@ class Zone(object): @property def records(self): + ''' + Get all records in this zone. + + Returns a set of all DNS records in the zone. If this is a shallow copy + (not yet hydrated), returns records from the origin zone. + + :return: Set of all records in the zone. + :rtype: set[octodns.record.base.Record] + ''' if self._origin: return self._origin.records return set([r for _, node in self._records.items() for r in node]) @property def root_ns(self): + ''' + Get the root NS record for this zone. + + The root NS record is the NS record at the zone apex (empty hostname). + Returns None if no root NS record exists. + + :return: The root NS record, or None if not present. + :rtype: octodns.record.ns.NsRecord or None + ''' if self._origin: return self._origin.root_ns return self._root_ns def hostname_from_fqdn(self, fqdn): + ''' + Extract the hostname portion from a fully qualified domain name. + + Strips the zone name from the FQDN to get just the hostname portion. + Handles both IDNA-encoded and UTF-8 domain names correctly. + + :param fqdn: Fully qualified domain name. + :type fqdn: str + + :return: The hostname portion (without the zone name). + :rtype: str + + Example:: + + zone = Zone('example.com.', []) + zone.hostname_from_fqdn('www.example.com.') # Returns 'www' + zone.hostname_from_fqdn('example.com.') # Returns '' + ''' try: fqdn.encode('ascii') # it's non-idna or idna encoded @@ -128,6 +273,27 @@ class Zone(object): return self._utf8_name_re.sub('', fqdn) def owns(self, _type, fqdn): + ''' + Determine if this zone owns a given FQDN for a specific record type. + + Checks whether a record with the given FQDN and type should be managed + by this zone, taking into account sub-zone boundaries. Records under + sub-zones are not owned by the parent (except NS records at the exact + sub-zone boundary). + + :param _type: The DNS record type (e.g., 'A', 'CNAME', 'NS'). + :type _type: str + :param fqdn: Fully qualified domain name to check. + :type fqdn: str + + :return: True if this zone owns the FQDN for this type, False otherwise. + :rtype: bool + + .. important:: + - NS records at sub-zone boundaries are owned by the parent zone + - All other records under sub-zones are not owned by the parent + - FQDNs are automatically normalized (trailing dot added if missing) + ''' if fqdn[-1] != '.': fqdn = f'{fqdn}.' @@ -154,6 +320,38 @@ class Zone(object): return True def add_record(self, record, replace=False, lenient=False): + ''' + Add a DNS record to this zone. + + Adds the provided record to the zone with validation. If this is a + shallow copy (has an origin), it will be hydrated before adding. + + :param record: The DNS record to add to the zone. + :type record: octodns.record.base.Record + :param replace: If True, replace any existing record with the same name + and type. If False, raise an exception if a duplicate + exists. + :type replace: bool + :param lenient: If True, skip some validation checks (sub-zone checks, + CNAME coexistence checks). Useful when loading existing + data that may not be standards-compliant. + :type lenient: bool + + :raises SubzoneRecordException: If the record belongs in a configured + sub-zone (unless it's an NS/DS record + at the boundary). + :raises DuplicateRecordException: If a record with the same name and type + already exists and ``replace=False``. + :raises InvalidNodeException: If adding the record would create an + invalid CNAME coexistence situation. + + .. important:: + - Automatically hydrates shallow copies on first modification + - NS/DS records are allowed at sub-zone boundaries + - CNAME records cannot coexist with other records at the same node + - Use ``replace=True`` to update existing records + - Use ``lenient=True`` when loading potentially non-compliant data + ''' if self._origin: self.hydrate() @@ -218,6 +416,21 @@ class Zone(object): node.add(record) def remove_record(self, record): + ''' + Remove a DNS record from this zone. + + Removes the provided record from the zone. If this is a shallow copy + (has an origin), it will be hydrated before removing. + + :param record: The DNS record to remove from the zone. + :type record: octodns.record.base.Record + + .. important:: + - Automatically hydrates shallow copies on first modification + - Clearing the root NS record (empty name) also clears the cached + ``root_ns`` property + - Silently succeeds if the record doesn't exist in the zone + ''' if self._origin: self.hydrate() @@ -235,6 +448,29 @@ class Zone(object): return self.remove_record(record) def changes(self, desired, target): + ''' + Compute the changes needed to transform this zone into the desired state. + + Compares this zone (existing state) with the desired zone and returns + a list of changes (Creates, Updates, Deletes) required to make this + zone match the desired state. Respects record-level include/exclude + filtering and provider support. + + :param desired: The desired zone state to compare against. + :type desired: Zone + :param target: The target provider that will apply these changes. Used + to check record support and apply include/exclude rules. + :type target: octodns.provider.base.BaseProvider + + :return: List of changes needed to transform this zone to the desired state. + :rtype: list[octodns.record.change.Change] + + .. important:: + - Skips records marked as ``ignored`` + - Respects record-level ``included`` and ``excluded`` lists + - Only includes changes for record types the target supports + - Returns Creates, Updates (via record.changes), and Deletes + ''' self.log.debug('changes: zone=%s, target=%s', self, target) # Build up a hash of the desired records, thanks to our special @@ -351,7 +587,20 @@ class Zone(object): def apply(self, changes): ''' - Apply the provided changes to the zone. + Apply a list of changes to this zone. + + Applies the provided changes by adding new/updated records and removing + deleted records. Uses ``replace=True`` and ``lenient=True`` to handle + updates and non-standard records gracefully. + + :param changes: List of changes to apply to the zone. + :type changes: list[octodns.record.change.Change] + + .. important:: + - Delete changes remove the existing record + - Create and Update changes add the new record with ``replace=True`` + - All adds use ``lenient=True`` to skip validation + - Changes are applied in the order provided ''' for change in changes: if isinstance(change, Delete): @@ -361,14 +610,25 @@ class Zone(object): def hydrate(self): ''' - Take a shallow copy Zone and make it a deeper copy holding its own - reference to records. These records will still be the originals and - they should not be modified. Changes should be made by calling - `add_record`, often with `replace=True`, and/or `remove_record`. - - Note: This method does not need to be called under normal circumstances - as `add_record` and `remove_record` will automatically call it when - appropriate. + Convert a shallow copy into a hydrated copy with its own record references. + + Hydration copies all records from the origin zone into this zone, + making it independent. The records themselves are still the original + objects and should not be modified directly. Use :meth:`add_record` + with ``replace=True`` or :meth:`remove_record` to make changes. + + :return: True if hydration occurred, False if already hydrated. + :rtype: bool + + .. note:: + This method is automatically called by :meth:`add_record` and + :meth:`remove_record` when needed, so manual calls are rarely necessary. + + .. important:: + - Only hydrates if this is a shallow copy (has an ``_origin``) + - Clears the ``_origin`` reference after hydration + - Uses ``lenient=True`` when adding records from origin + - Records are still shared with the origin (not deep copied) ''' origin = self._origin if origin is None: @@ -383,14 +643,33 @@ class Zone(object): def copy(self): ''' - Copy-on-write semantics support. This method will create a shallow - clone of the zone which will be hydrated the first time `add_record` or - `remove_record` is called. - - This allows low-cost copies of things to be made in situations where - changes are unlikely and only incurs the "expense" of actually - copying the records when required. The actual record copy will not be - "deep" meaning that records should not be modified directly. + Create a shallow copy of this zone using copy-on-write semantics. + + Creates a new zone that shares records with this zone until the copy + is modified. When :meth:`add_record` or :meth:`remove_record` is called + on the copy, it will be automatically hydrated with its own record + references. + + :return: A shallow copy of this zone. + :rtype: Zone + + .. important:: + - The copy shares records with the original until hydrated + - Hydration happens automatically on first modification + - Records in the hydrated copy are still the same objects (not deep copied) + - Modifying records directly affects both zones; use ``record.copy()`` + and ``add_record(..., replace=True)`` instead + + Example:: + + original = Zone('example.com.', []) + # ... add records to original ... + + copy = original.copy() # Shallow copy, shares records + # No copying has occurred yet + + copy.add_record(new_record) # Triggers hydration, copies record refs + # Now copy has its own record references ''' copy = Zone( self.name, @@ -402,4 +681,10 @@ class Zone(object): return copy def __repr__(self): + ''' + Return a string representation of this zone. + + :return: String in the format ``Zone`` using the decoded name. + :rtype: str + ''' return f'Zone<{self.decoded_name}>'