mirror of
				https://github.com/github/octodns.git
				synced 2024-05-11 05:55:00 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			591 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			591 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #
 | |
| #
 | |
| #
 | |
| 
 | |
| from __future__ import absolute_import, division, print_function, \
 | |
|     unicode_literals
 | |
| 
 | |
| from concurrent.futures import ThreadPoolExecutor
 | |
| from importlib import import_module
 | |
| from os import environ
 | |
| from sys import stdout
 | |
| import logging
 | |
| 
 | |
| from .provider.base import BaseProvider
 | |
| from .provider.plan import Plan
 | |
| from .provider.yaml import SplitYamlProvider, YamlProvider
 | |
| from .record import Record
 | |
| from .yaml import safe_load
 | |
| from .zone import Zone
 | |
| 
 | |
| 
 | |
| class _AggregateTarget(object):
 | |
|     id = 'aggregate'
 | |
| 
 | |
|     def __init__(self, targets):
 | |
|         self.targets = targets
 | |
| 
 | |
|     def supports(self, record):
 | |
|         for target in self.targets:
 | |
|             if not target.supports(record):
 | |
|                 return False
 | |
|         return True
 | |
| 
 | |
|     @property
 | |
|     def SUPPORTS_GEO(self):
 | |
|         for target in self.targets:
 | |
|             if not target.SUPPORTS_GEO:
 | |
|                 return False
 | |
|         return True
 | |
| 
 | |
|     @property
 | |
|     def SUPPORTS_DYNAMIC(self):
 | |
|         for target in self.targets:
 | |
|             if not target.SUPPORTS_DYNAMIC:
 | |
|                 return False
 | |
|         return True
 | |
| 
 | |
| 
 | |
| class MakeThreadFuture(object):
 | |
| 
 | |
|     def __init__(self, func, args, kwargs):
 | |
|         self.func = func
 | |
|         self.args = args
 | |
|         self.kwargs = kwargs
 | |
| 
 | |
|     def result(self):
 | |
|         return self.func(*self.args, **self.kwargs)
 | |
| 
 | |
| 
 | |
| class MainThreadExecutor(object):
 | |
|     '''
 | |
|     Dummy executor that runs things on the main thread during the invocation
 | |
|     of submit, but still returns a future object with the result. This allows
 | |
|     code to be written to handle async, even in the case where we don't want to
 | |
|     use multiple threads/workers and would prefer that things flow as if
 | |
|     traditionally written.
 | |
|     '''
 | |
| 
 | |
|     def submit(self, func, *args, **kwargs):
 | |
|         return MakeThreadFuture(func, args, kwargs)
 | |
| 
 | |
| 
 | |
| class ManagerException(Exception):
 | |
|     pass
 | |
| 
 | |
| 
 | |
| class Manager(object):
 | |
|     log = logging.getLogger('Manager')
 | |
| 
 | |
|     @classmethod
 | |
|     def _plan_keyer(cls, p):
 | |
|         plan = p[1]
 | |
|         return len(plan.changes[0].record.zone.name) if plan.changes else 0
 | |
| 
 | |
|     def __init__(self, config_file, max_workers=None, include_meta=False):
 | |
|         self.log.info('__init__: config_file=%s', config_file)
 | |
| 
 | |
|         # Read our config file
 | |
|         with open(config_file, 'r') as fh:
 | |
|             self.config = safe_load(fh, enforce_order=False)
 | |
| 
 | |
|         manager_config = self.config.get('manager', {})
 | |
|         max_workers = manager_config.get('max_workers', 1) \
 | |
|             if max_workers is None else max_workers
 | |
|         self.log.info('__init__:   max_workers=%d', max_workers)
 | |
|         if max_workers > 1:
 | |
|             self._executor = ThreadPoolExecutor(max_workers=max_workers)
 | |
|         else:
 | |
|             self._executor = MainThreadExecutor()
 | |
| 
 | |
|         self.include_meta = include_meta or manager_config.get('include_meta',
 | |
|                                                                False)
 | |
|         self.log.info('__init__:   include_meta=%s', self.include_meta)
 | |
| 
 | |
|         self.log.debug('__init__:   configuring providers')
 | |
|         self.providers = {}
 | |
|         for provider_name, provider_config in self.config['providers'].items():
 | |
|             # Get our class and remove it from the provider_config
 | |
|             try:
 | |
|                 _class = provider_config.pop('class')
 | |
|             except KeyError:
 | |
|                 self.log.exception('Invalid provider class')
 | |
|                 raise ManagerException(f'Provider {provider_name} is missing '
 | |
|                                        'class')
 | |
|             _class = self._get_named_class('provider', _class)
 | |
|             kwargs = self._build_kwargs(provider_config)
 | |
|             try:
 | |
|                 self.providers[provider_name] = _class(provider_name, **kwargs)
 | |
|             except TypeError:
 | |
|                 self.log.exception('Invalid provider config')
 | |
|                 raise ManagerException('Incorrect provider config for ' +
 | |
|                                        provider_name)
 | |
| 
 | |
|         self.processors = {}
 | |
|         for processor_name, processor_config in \
 | |
|                 self.config.get('processors', {}).items():
 | |
|             try:
 | |
|                 _class = processor_config.pop('class')
 | |
|             except KeyError:
 | |
|                 self.log.exception('Invalid processor class')
 | |
|                 raise ManagerException(f'Processor {processor_name} is '
 | |
|                                        'missing class')
 | |
|             _class = self._get_named_class('processor', _class)
 | |
|             kwargs = self._build_kwargs(processor_config)
 | |
|             try:
 | |
|                 self.processors[processor_name] = _class(processor_name,
 | |
|                                                          **kwargs)
 | |
|             except TypeError:
 | |
|                 self.log.exception('Invalid processor config')
 | |
|                 raise ManagerException('Incorrect processor config for ' +
 | |
|                                        processor_name)
 | |
| 
 | |
|         zone_tree = {}
 | |
|         # sort by reversed strings so that parent zones always come first
 | |
|         for name in sorted(self.config['zones'].keys(), key=lambda s: s[::-1]):
 | |
|             # ignore trailing dots, and reverse
 | |
|             pieces = name[:-1].split('.')[::-1]
 | |
|             # where starts out at the top
 | |
|             where = zone_tree
 | |
|             # for all the pieces
 | |
|             for piece in pieces:
 | |
|                 try:
 | |
|                     where = where[piece]
 | |
|                     # our current piece already exists, just point where at
 | |
|                     # it's value
 | |
|                 except KeyError:
 | |
|                     # our current piece doesn't exist, create it
 | |
|                     where[piece] = {}
 | |
|                     # and then point where at it's newly created value
 | |
|                     where = where[piece]
 | |
|         self.zone_tree = zone_tree
 | |
| 
 | |
|         self.plan_outputs = {}
 | |
|         plan_outputs = manager_config.get('plan_outputs', {
 | |
|             'logger': {
 | |
|                 'class': 'octodns.provider.plan.PlanLogger',
 | |
|                 'level': 'info'
 | |
|             }
 | |
|         })
 | |
|         for plan_output_name, plan_output_config in plan_outputs.items():
 | |
|             try:
 | |
|                 _class = plan_output_config.pop('class')
 | |
|             except KeyError:
 | |
|                 self.log.exception('Invalid plan_output class')
 | |
|                 raise ManagerException(f'plan_output {plan_output_name} is '
 | |
|                                        'missing class')
 | |
|             _class = self._get_named_class('plan_output', _class)
 | |
|             kwargs = self._build_kwargs(plan_output_config)
 | |
|             try:
 | |
|                 self.plan_outputs[plan_output_name] = \
 | |
|                     _class(plan_output_name, **kwargs)
 | |
|             except TypeError:
 | |
|                 self.log.exception('Invalid plan_output config')
 | |
|                 raise ManagerException('Incorrect plan_output config for ' +
 | |
|                                        plan_output_name)
 | |
| 
 | |
|     def _get_named_class(self, _type, _class):
 | |
|         try:
 | |
|             module_name, class_name = _class.rsplit('.', 1)
 | |
|             module = import_module(module_name)
 | |
|         except (ImportError, ValueError):
 | |
|             self.log.exception('_get_{}_class: Unable to import '
 | |
|                                'module %s', _class)
 | |
|             raise ManagerException(f'Unknown {_type} class: {_class}')
 | |
|         try:
 | |
|             return getattr(module, class_name)
 | |
|         except AttributeError:
 | |
|             self.log.exception('_get_{}_class: Unable to get class %s '
 | |
|                                'from module %s', class_name, module)
 | |
|             raise ManagerException(f'Unknown {_type} class: {_class}')
 | |
| 
 | |
|     def _build_kwargs(self, source):
 | |
|         # Build up the arguments we need to pass to the provider
 | |
|         kwargs = {}
 | |
|         for k, v in source.items():
 | |
|             try:
 | |
|                 if v.startswith('env/'):
 | |
|                     try:
 | |
|                         env_var = v[4:]
 | |
|                         v = environ[env_var]
 | |
|                     except KeyError:
 | |
|                         self.log.exception('Invalid provider config')
 | |
|                         raise ManagerException('Incorrect provider config, '
 | |
|                                                'missing env var ' + env_var)
 | |
|             except AttributeError:
 | |
|                 pass
 | |
|             kwargs[k] = v
 | |
| 
 | |
|         return kwargs
 | |
| 
 | |
|     def configured_sub_zones(self, zone_name):
 | |
|         # Reversed pieces of the zone name
 | |
|         pieces = zone_name[:-1].split('.')[::-1]
 | |
|         # Point where at the root of the tree
 | |
|         where = self.zone_tree
 | |
|         # Until we've hit the bottom of this zone
 | |
|         try:
 | |
|             while pieces:
 | |
|                 # Point where at the value of our current piece
 | |
|                 where = where[pieces.pop(0)]
 | |
|         except KeyError:
 | |
|             self.log.debug('configured_sub_zones: unknown zone, %s, no subs',
 | |
|                            zone_name)
 | |
|             return set()
 | |
|         # We're not pointed at the dict for our name, the keys of which will be
 | |
|         # any subzones
 | |
|         sub_zone_names = where.keys()
 | |
|         self.log.debug('configured_sub_zones: subs=%s', sub_zone_names)
 | |
|         return set(sub_zone_names)
 | |
| 
 | |
|     def _populate_and_plan(self, zone_name, processors, sources, targets,
 | |
|                            desired=None, lenient=False):
 | |
| 
 | |
|         self.log.debug('sync:   populating, zone=%s, lenient=%s',
 | |
|                        zone_name, lenient)
 | |
|         zone = Zone(zone_name,
 | |
|                     sub_zones=self.configured_sub_zones(zone_name))
 | |
| 
 | |
|         if desired:
 | |
|             # This is an alias zone, rather than populate it we'll copy the
 | |
|             # records over from `desired`.
 | |
|             for _, records in desired._records.items():
 | |
|                 for record in records:
 | |
|                     zone.add_record(record.copy(zone=zone), lenient=lenient)
 | |
|         else:
 | |
|             for source in sources:
 | |
|                 try:
 | |
|                     source.populate(zone, lenient=lenient)
 | |
|                 except TypeError as e:
 | |
|                     if ("unexpected keyword argument 'lenient'"
 | |
|                             not in str(e)):
 | |
|                         raise
 | |
|                     self.log.warn('provider %s does not accept lenient '
 | |
|                                   'param', source.__class__.__name__)
 | |
|                     source.populate(zone)
 | |
| 
 | |
|         for processor in processors:
 | |
|             zone = processor.process_source_zone(zone, sources=sources)
 | |
| 
 | |
|         self.log.debug('sync:   planning, zone=%s', zone_name)
 | |
|         plans = []
 | |
| 
 | |
|         for target in targets:
 | |
|             if self.include_meta:
 | |
|                 meta = Record.new(zone, 'octodns-meta', {
 | |
|                     'type': 'TXT',
 | |
|                     'ttl': 60,
 | |
|                     'value': f'provider={target.id}',
 | |
|                 })
 | |
|                 zone.add_record(meta, replace=True)
 | |
|             try:
 | |
|                 plan = target.plan(zone, processors=processors)
 | |
|             except TypeError as e:
 | |
|                 if "keyword argument 'processors'" not in str(e):
 | |
|                     raise
 | |
|                 self.log.warn('provider.plan %s does not accept processors '
 | |
|                               'param', target.__class__.__name__)
 | |
|                 plan = target.plan(zone)
 | |
| 
 | |
|             for processor in processors:
 | |
|                 plan = processor.process_plan(plan, sources=sources,
 | |
|                                               target=target)
 | |
|             if plan:
 | |
|                 plans.append((target, plan))
 | |
| 
 | |
|         # Return the zone as it's the desired state
 | |
|         return plans, zone
 | |
| 
 | |
|     def sync(self, eligible_zones=[], eligible_sources=[], eligible_targets=[],
 | |
|              dry_run=True, force=False, plan_output_fh=stdout):
 | |
| 
 | |
|         self.log.info(
 | |
|             'sync: eligible_zones=%s, eligible_targets=%s, dry_run=%s, '
 | |
|             'force=%s, plan_output_fh=%s',
 | |
|             eligible_zones, eligible_targets, dry_run, force,
 | |
|             getattr(plan_output_fh, 'name', plan_output_fh.__class__.__name__))
 | |
| 
 | |
|         zones = self.config['zones'].items()
 | |
|         if eligible_zones:
 | |
|             zones = [z for z in zones if z[0] in eligible_zones]
 | |
| 
 | |
|         aliased_zones = {}
 | |
|         futures = []
 | |
|         for zone_name, config in zones:
 | |
|             self.log.info('sync:   zone=%s', zone_name)
 | |
|             if 'alias' in config:
 | |
|                 source_zone = config['alias']
 | |
| 
 | |
|                 # Check that the source zone is defined.
 | |
|                 if source_zone not in self.config['zones']:
 | |
|                     self.log.error(f'Invalid alias zone {zone_name}, '
 | |
|                                    f'target {source_zone} does not exist')
 | |
|                     raise ManagerException(f'Invalid alias zone {zone_name}: '
 | |
|                                            f'source zone {source_zone} does '
 | |
|                                            'not exist')
 | |
| 
 | |
|                 # Check that the source zone is not an alias zone itself.
 | |
|                 if 'alias' in self.config['zones'][source_zone]:
 | |
|                     self.log.error(f'Invalid alias zone {zone_name}, '
 | |
|                                    f'target {source_zone} is an alias zone')
 | |
|                     raise ManagerException(f'Invalid alias zone {zone_name}: '
 | |
|                                            f'source zone {source_zone} is an '
 | |
|                                            'alias zone')
 | |
| 
 | |
|                 aliased_zones[zone_name] = source_zone
 | |
|                 continue
 | |
| 
 | |
|             lenient = config.get('lenient', False)
 | |
|             try:
 | |
|                 sources = config['sources']
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name} is missing sources')
 | |
| 
 | |
|             try:
 | |
|                 targets = config['targets']
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name} is missing targets')
 | |
| 
 | |
|             processors = config.get('processors', [])
 | |
| 
 | |
|             if (eligible_sources and not
 | |
|                     [s for s in sources if s in eligible_sources]):
 | |
|                 self.log.info('sync:   no eligible sources, skipping')
 | |
|                 continue
 | |
| 
 | |
|             if eligible_targets:
 | |
|                 targets = [t for t in targets if t in eligible_targets]
 | |
| 
 | |
|             if not targets:
 | |
|                 # Don't bother planning (and more importantly populating) zones
 | |
|                 # when we don't have any eligible targets, waste of
 | |
|                 # time/resources
 | |
|                 self.log.info('sync:   no eligible targets, skipping')
 | |
|                 continue
 | |
| 
 | |
|             self.log.info('sync:   sources=%s -> targets=%s', sources, targets)
 | |
| 
 | |
|             try:
 | |
|                 collected = []
 | |
|                 for processor in processors:
 | |
|                     collected.append(self.processors[processor])
 | |
|                 processors = collected
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name}, unknown '
 | |
|                                        f'processor: {processor}')
 | |
| 
 | |
|             try:
 | |
|                 # rather than using a list comprehension, we break this loop
 | |
|                 # out so that the `except` block below can reference the
 | |
|                 # `source`
 | |
|                 collected = []
 | |
|                 for source in sources:
 | |
|                     collected.append(self.providers[source])
 | |
|                 sources = collected
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name}, unknown '
 | |
|                                        f'source: {source}')
 | |
| 
 | |
|             try:
 | |
|                 trgs = []
 | |
|                 for target in targets:
 | |
|                     trg = self.providers[target]
 | |
|                     if not isinstance(trg, BaseProvider):
 | |
|                         raise ManagerException(f'{trg} - "{target}" does not '
 | |
|                                                'support targeting')
 | |
|                     trgs.append(trg)
 | |
|                 targets = trgs
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name}, unknown '
 | |
|                                        f'target: {target}')
 | |
| 
 | |
|             futures.append(self._executor.submit(self._populate_and_plan,
 | |
|                                                  zone_name, processors,
 | |
|                                                  sources, targets,
 | |
|                                                  lenient=lenient))
 | |
| 
 | |
|         # Wait on all results and unpack/flatten the plans and store the
 | |
|         # desired states in case we need them below
 | |
|         plans = []
 | |
|         desired = {}
 | |
|         for future in futures:
 | |
|             ps, d = future.result()
 | |
|             desired[d.name] = d
 | |
|             for plan in ps:
 | |
|                 plans.append(plan)
 | |
| 
 | |
|         # Populate aliases zones.
 | |
|         futures = []
 | |
|         for zone_name, zone_source in aliased_zones.items():
 | |
|             source_config = self.config['zones'][zone_source]
 | |
|             try:
 | |
|                 desired_config = desired[zone_source]
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name} cannot be sync '
 | |
|                                        f'without zone {zone_source} sinced '
 | |
|                                        'it is aliased')
 | |
|             futures.append(self._executor.submit(
 | |
|                 self._populate_and_plan,
 | |
|                 zone_name,
 | |
|                 processors,
 | |
|                 [],
 | |
|                 [self.providers[t] for t in source_config['targets']],
 | |
|                 desired=desired_config,
 | |
|                 lenient=lenient
 | |
|             ))
 | |
| 
 | |
|         # Wait on results and unpack/flatten the plans, ignore the desired here
 | |
|         # as these are aliased zones
 | |
|         plans += [p for f in futures for p in f.result()[0]]
 | |
| 
 | |
|         # Best effort sort plans children first so that we create/update
 | |
|         # children zones before parents which should allow us to more safely
 | |
|         # extract things into sub-zones. Combining a child back into a parent
 | |
|         # can't really be done all that safely in general so we'll optimize for
 | |
|         # this direction.
 | |
|         plans.sort(key=self._plan_keyer, reverse=True)
 | |
| 
 | |
|         for output in self.plan_outputs.values():
 | |
|             output.run(plans=plans, log=self.log, fh=plan_output_fh)
 | |
| 
 | |
|         if not force:
 | |
|             self.log.debug('sync:   checking safety')
 | |
|             for target, plan in plans:
 | |
|                 plan.raise_if_unsafe()
 | |
| 
 | |
|         if dry_run:
 | |
|             return 0
 | |
| 
 | |
|         total_changes = 0
 | |
|         self.log.debug('sync:   applying')
 | |
|         zones = self.config['zones']
 | |
|         for target, plan in plans:
 | |
|             zone_name = plan.existing.name
 | |
|             if zones[zone_name].get('always-dry-run', False):
 | |
|                 self.log.info('sync: zone=%s skipping always-dry-run',
 | |
|                               zone_name)
 | |
|                 continue
 | |
|             total_changes += target.apply(plan)
 | |
| 
 | |
|         self.log.info('sync:   %d total changes', total_changes)
 | |
|         return total_changes
 | |
| 
 | |
|     def compare(self, a, b, zone):
 | |
|         '''
 | |
|         Compare zone data between 2 sources.
 | |
| 
 | |
|         Note: only things supported by both sources will be considered
 | |
|         '''
 | |
|         self.log.info('compare: a=%s, b=%s, zone=%s', a, b, zone)
 | |
| 
 | |
|         try:
 | |
|             a = [self.providers[source] for source in a]
 | |
|             b = [self.providers[source] for source in b]
 | |
|         except KeyError as e:
 | |
|             raise ManagerException(f'Unknown source: {e.args[0]}')
 | |
| 
 | |
|         za = self.get_zone(zone)
 | |
|         for source in a:
 | |
|             source.populate(za)
 | |
| 
 | |
|         zb = self.get_zone(zone)
 | |
|         for source in b:
 | |
|             source.populate(zb)
 | |
| 
 | |
|         return zb.changes(za, _AggregateTarget(a + b))
 | |
| 
 | |
|     def dump(self, zone, output_dir, lenient, split, source, *sources):
 | |
|         '''
 | |
|         Dump zone data from the specified source
 | |
|         '''
 | |
|         self.log.info('dump: zone=%s, sources=%s', zone, sources)
 | |
| 
 | |
|         # We broke out source to force at least one to be passed, add it to any
 | |
|         # others we got.
 | |
|         sources = [source] + list(sources)
 | |
| 
 | |
|         try:
 | |
|             sources = [self.providers[s] for s in sources]
 | |
|         except KeyError as e:
 | |
|             raise ManagerException(f'Unknown source: {e.args[0]}')
 | |
| 
 | |
|         clz = YamlProvider
 | |
|         if split:
 | |
|             clz = SplitYamlProvider
 | |
|         target = clz('dump', output_dir)
 | |
| 
 | |
|         zone = Zone(zone, self.configured_sub_zones(zone))
 | |
|         for source in sources:
 | |
|             source.populate(zone, lenient=lenient)
 | |
| 
 | |
|         plan = target.plan(zone)
 | |
|         if plan is None:
 | |
|             plan = Plan(zone, zone, [], False)
 | |
|         target.apply(plan)
 | |
| 
 | |
|     def validate_configs(self):
 | |
|         for zone_name, config in self.config['zones'].items():
 | |
|             zone = Zone(zone_name, self.configured_sub_zones(zone_name))
 | |
| 
 | |
|             source_zone = config.get('alias')
 | |
|             if source_zone:
 | |
|                 if source_zone not in self.config['zones']:
 | |
|                     self.log.exception('Invalid alias zone')
 | |
|                     raise ManagerException(f'Invalid alias zone {zone_name}: '
 | |
|                                            f'source zone {source_zone} does '
 | |
|                                            'not exist')
 | |
| 
 | |
|                 if 'alias' in self.config['zones'][source_zone]:
 | |
|                     self.log.exception('Invalid alias zone')
 | |
|                     raise ManagerException(f'Invalid alias zone {zone_name}: '
 | |
|                                            'source zone {source_zone} is an '
 | |
|                                            'alias zone')
 | |
| 
 | |
|                 # this is just here to satisfy coverage, see
 | |
|                 # https://github.com/nedbat/coveragepy/issues/198
 | |
|                 source_zone = source_zone
 | |
|                 continue
 | |
| 
 | |
|             lenient = config.get('lenient', False)
 | |
|             try:
 | |
|                 sources = config['sources']
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name} is missing sources')
 | |
| 
 | |
|             try:
 | |
|                 # rather than using a list comprehension, we break this
 | |
|                 # loop out so that the `except` block below can reference
 | |
|                 # the `source`
 | |
|                 collected = []
 | |
|                 for source in sources:
 | |
|                     collected.append(self.providers[source])
 | |
|                 sources = collected
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name}, unknown source: ' +
 | |
|                                        source)
 | |
| 
 | |
|             for source in sources:
 | |
|                 if isinstance(source, YamlProvider):
 | |
|                     source.populate(zone, lenient=lenient)
 | |
| 
 | |
|             # check that processors are in order if any are specified
 | |
|             processors = config.get('processors', [])
 | |
|             try:
 | |
|                 # same as above, but for processors this time
 | |
|                 for processor in processors:
 | |
|                     collected.append(self.processors[processor])
 | |
|             except KeyError:
 | |
|                 raise ManagerException(f'Zone {zone_name}, unknown '
 | |
|                                        f'processor: {processor}')
 | |
| 
 | |
|     def get_zone(self, zone_name):
 | |
|         if not zone_name[-1] == '.':
 | |
|             raise ManagerException(f'Invalid zone name {zone_name}, missing '
 | |
|                                    'ending dot')
 | |
| 
 | |
|         for name, config in self.config['zones'].items():
 | |
|             if name == zone_name:
 | |
|                 return Zone(name, self.configured_sub_zones(name))
 | |
| 
 | |
|         raise ManagerException(f'Unknown zone name {zone_name}')
 |