Source code for octodns.provider.plan

#
#
#

from collections import defaultdict
from io import StringIO
from json import dumps
from logging import DEBUG, ERROR, INFO, WARN, getLogger
from pprint import pformat
from sys import stdout


[docs] class UnsafePlan(Exception): pass
[docs] class RootNsChange(UnsafePlan):
[docs] def __init__(self): super().__init__('Root NS record change, force required')
[docs] class TooMuchChange(UnsafePlan):
[docs] def __init__( self, why, update_pcent, update_threshold, change_count, existing_count, name, ): msg = ( f'[{name}] {why}, {update_pcent:.2f}% is over {update_threshold:.2f}% ' f'({change_count}/{existing_count}), force required' ) super().__init__(msg)
[docs] class Plan(object): log = getLogger('Plan') MAX_SAFE_UPDATE_PCENT = 0.3 MAX_SAFE_DELETE_PCENT = 0.3 MIN_EXISTING_RECORDS = 10
[docs] def __init__( self, existing, desired, changes, exists, update_pcent_threshold=MAX_SAFE_UPDATE_PCENT, delete_pcent_threshold=MAX_SAFE_DELETE_PCENT, meta=None, ): self.existing = existing self.desired = desired # Sort changes to ensure we always have a consistent ordering for # things that make assumptions about that. Many providers will do their # own ordering to ensure things happen in a way that makes sense to # them and/or is as safe as possible. self.changes = sorted(changes) self.exists = exists self.meta = meta # Zone thresholds take precedence over provider if existing and existing.update_pcent_threshold is not None: self.update_pcent_threshold = existing.update_pcent_threshold else: self.update_pcent_threshold = update_pcent_threshold if existing and existing.delete_pcent_threshold is not None: self.delete_pcent_threshold = existing.delete_pcent_threshold else: self.delete_pcent_threshold = delete_pcent_threshold change_counts = {'Create': 0, 'Delete': 0, 'Update': 0} for change in changes: change_counts[change.__class__.__name__] += 1 self.change_counts = change_counts self.log.debug('__init__: %s', self.__repr__())
@property def data(self): return {'changes': [c.data for c in self.changes], 'meta': self.meta}
[docs] def raise_if_unsafe(self): if ( self.existing and len(self.existing.records) >= self.MIN_EXISTING_RECORDS ): existing_record_count = len(self.existing.records) if existing_record_count > 0: update_pcent = ( self.change_counts['Update'] / existing_record_count ) delete_pcent = ( self.change_counts['Delete'] / existing_record_count ) else: update_pcent = 0 delete_pcent = 0 if update_pcent > self.update_pcent_threshold: raise TooMuchChange( 'Too many updates', update_pcent * 100, self.update_pcent_threshold * 100, self.change_counts['Update'], existing_record_count, self.existing.decoded_name, ) if delete_pcent > self.delete_pcent_threshold: raise TooMuchChange( 'Too many deletes', delete_pcent * 100, self.delete_pcent_threshold * 100, self.change_counts['Delete'], existing_record_count, self.existing.decoded_name, ) # If we have any changes of the root NS record for the zone it's a huge # deal and force should always be required for extra care if self.exists and any( c for c in self.changes if c.record and c.record._type == 'NS' and c.record.name == '' ): raise RootNsChange()
[docs] def __repr__(self): creates = self.change_counts['Create'] updates = self.change_counts['Update'] deletes = self.change_counts['Delete'] try: existing = len(self.existing.records) except AttributeError: existing = 0 meta = self.meta is not None return f'Creates={creates}, Updates={updates}, Deletes={deletes}, Existing={existing}, Meta={meta}'
[docs] class _PlanOutput(object):
[docs] def __init__(self, name): self.name = name
[docs] class PlanLogger(_PlanOutput):
[docs] def __init__(self, name, level='info'): super().__init__(name) try: self.level = { 'debug': DEBUG, 'info': INFO, 'warn': WARN, 'warning': WARN, 'error': ERROR, }[level.lower()] except (AttributeError, KeyError): raise Exception(f'Unsupported level: {level}')
[docs] def run(self, log, plans, *args, **kwargs): hr = '********************************************************************************\n' buf = StringIO() buf.write('\n') if plans: current_zone = None for target, plan in plans: if plan.desired.decoded_name != current_zone: current_zone = plan.desired.decoded_name buf.write(hr) buf.write('* ') buf.write(current_zone) buf.write('\n') buf.write(hr) buf.write('* ') buf.write(target.id) buf.write(' (') buf.write(str(target)) buf.write(')\n* ') if plan.exists is False: buf.write('Create ') buf.write(str(plan.desired)) buf.write('\n* ') for change in plan.changes: buf.write(change.__repr__(leader='* ')) buf.write('\n* ') if plan.meta: buf.write('Meta: \n') buf.write(pformat(plan.meta, indent=2, sort_dicts=True)) buf.write('\n') buf.write('Summary: ') buf.write(str(plan)) buf.write('\n') else: buf.write(hr) buf.write('No changes were planned\n') buf.write(hr) buf.write('\n') log.log(self.level, buf.getvalue())
[docs] def _value_stringifier(record, sep): try: values = [str(v) for v in record.values] except AttributeError: values = [record.value] for code, gv in sorted(getattr(record, 'geo', {}).items()): vs = ', '.join([str(v) for v in gv.values]) values.append(f'{code}: {vs}') return sep.join(values)
[docs] class PlanJson(_PlanOutput):
[docs] def __init__(self, name, indent=None, sort_keys=True): super().__init__(name) self.indent = indent self.sort_keys = sort_keys
[docs] def run(self, plans, fh=stdout, *args, **kwargs): data = defaultdict(dict) for target, plan in plans: data[target.id][plan.desired.name] = plan.data fh.write(dumps(data, indent=self.indent, sort_keys=self.sort_keys)) fh.write('\n')
[docs] class PlanMarkdown(_PlanOutput):
[docs] def run(self, plans, fh=stdout, *args, **kwargs): if plans: current_zone = None for target, plan in plans: if plan.desired.decoded_name != current_zone: current_zone = plan.desired.decoded_name fh.write('## ') fh.write(current_zone) fh.write('\n\n') fh.write('### ') fh.write(target.id) fh.write('\n\n') fh.write( '| Operation | Name | Type | TTL | Value | Source |\n' '|--|--|--|--|--|--|\n' ) if plan.exists is False: fh.write('| Create | ') fh.write(str(plan.desired)) fh.write(' | | | | |\n') for change in plan.changes: existing = change.existing new = change.new record = change.record fh.write('| ') fh.write(change.__class__.__name__) fh.write(' | ') fh.write(record.name) fh.write(' | ') fh.write(record._type) fh.write(' | ') # TTL if existing: fh.write(str(existing.ttl)) fh.write(' | ') fh.write(_value_stringifier(existing, '; ')) fh.write(' | |\n') if new: fh.write('| | | | ') if new: fh.write(str(new.ttl)) fh.write(' | ') fh.write(_value_stringifier(new, '; ')) fh.write(' | ') if new.source: fh.write(new.source.id) fh.write(' |\n') if plan.meta: fh.write('\nMeta: ') fh.write(pformat(plan.meta, indent=2, sort_dicts=True)) fh.write('\n') fh.write('\nSummary: ') fh.write(str(plan)) fh.write('\n\n') else: fh.write('## No changes were planned\n')
[docs] class PlanHtml(_PlanOutput):
[docs] def run(self, plans, fh=stdout, *args, **kwargs): if plans: current_zone = None for target, plan in plans: if plan.desired.decoded_name != current_zone: current_zone = plan.desired.decoded_name fh.write('<h2>') fh.write(current_zone) fh.write('</h2>\n') fh.write('<h3>') fh.write(target.id) fh.write( '''</h3> <table> <tr> <th>Operation</th> <th>Name</th> <th>Type</th> <th>TTL</th> <th>Value</th> <th>Source</th> </tr> ''' ) if plan.exists is False: fh.write(' <tr>\n <td>Create</td>\n <td colspan=5>') fh.write(str(plan.desired)) fh.write('</td>\n </tr>\n') for change in plan.changes: existing = change.existing new = change.new record = change.record fh.write(' <tr>\n <td>') fh.write(change.__class__.__name__) fh.write('</td>\n <td>') fh.write(record.name) fh.write('</td>\n <td>') fh.write(record._type) fh.write('</td>\n') # TTL if existing: fh.write(' <td>') fh.write(str(existing.ttl)) fh.write('</td>\n <td>') fh.write(_value_stringifier(existing, '<br/>')) fh.write('</td>\n <td></td>\n </tr>\n') if new: fh.write(' <tr>\n <td colspan=3></td>\n') if new: fh.write(' <td>') fh.write(str(new.ttl)) fh.write('</td>\n <td>') fh.write(_value_stringifier(new, '<br/>')) fh.write('</td>\n <td>') if new.source: fh.write(new.source.id) fh.write('</td>\n </tr>\n') if plan.meta: fh.write(' <tr>\n <td colspan=6>Meta: ') fh.write(pformat(plan.meta, indent=2, sort_dicts=True)) fh.write('</td>\n </tr>\n</table>\n') fh.write(' <tr>\n <td colspan=6>Summary: ') fh.write(str(plan)) fh.write('</td>\n </tr>\n</table>\n') else: fh.write('<b>No changes were planned</b>')