# # # from __future__ import absolute_import, division, print_function, \ unicode_literals from collections import defaultdict from logging import getLogger import re from .record import Create, Delete class SubzoneRecordException(Exception): pass class DuplicateRecordException(Exception): pass class InvalidNodeException(Exception): pass def _is_eligible(record): # Should this record be considered when computing changes # We ignore all top-level NS records return record._type != 'NS' or record.name != '' class Zone(object): log = getLogger('Zone') def __init__(self, name, sub_zones): if not name[-1] == '.': raise Exception('Invalid zone name {}, missing ending dot' .format(name)) # Force everyting to lowercase just to be safe self.name = str(name).lower() if name else name self.sub_zones = sub_zones # We're grouping by node, it allows us to efficently search for # duplicates and detect when CNAMEs co-exist with other records self._records = defaultdict(set) # optional leading . to match empty hostname # optional trailing . b/c some sources don't have it on their fqdn self._name_re = re.compile('\.?{}?$'.format(name)) self.log.debug('__init__: zone=%s, sub_zones=%s', self, sub_zones) @property def records(self): return set([r for _, node in self._records.items() for r in node]) def hostname_from_fqdn(self, fqdn): return self._name_re.sub('', fqdn) def add_record(self, record, replace=False): name = record.name last = name.split('.')[-1] if last in self.sub_zones: if name != last: # it's a record for something under a sub-zone raise SubzoneRecordException('Record {} is under a ' 'managed subzone' .format(record.fqdn)) elif record._type != 'NS': # It's a non NS record for exactly a sub-zone raise SubzoneRecordException('Record {} a managed sub-zone ' 'and not of type NS' .format(record.fqdn)) if replace: # will remove it if it exists self._records[name].discard(record) node = self._records[name] if record in node: # We already have a record at this node of this type raise DuplicateRecordException('Duplicate record {}, type {}' .format(record.fqdn, record._type)) elif ((record._type == 'CNAME' and len(node) > 0) or ('CNAME' in map(lambda r: r._type, node))): # We're adding a CNAME to existing records or adding to an existing # CNAME raise InvalidNodeException('Invalid state, CNAME at {} cannot ' 'coexist with other records' .format(record.fqdn)) node.add(record) def _remove_record(self, record): 'Only for use in tests' self._records[record.name].discard(record) def changes(self, desired, target): self.log.debug('changes: zone=%s, target=%s', self, target) # Build up a hash of the desired records, thanks to our special # __hash__ and __cmp__ on Record we'll be able to look up records that # match name and _type with it desired_records = {r: r for r in desired.records} changes = [] # Find diffs & removes for record in filter(_is_eligible, self.records): if record.ignored: continue elif len(record.included) > 0 and \ target.id not in record.included: self.log.debug('changes: skipping record=%s %s - %s not' ' included ', record.fqdn, record._type, target.id) continue elif target.id in record.excluded: self.log.debug('changes: skipping record=%s %s - %s ' 'excluded ', record.fqdn, record._type, target.id) continue try: desired_record = desired_records[record] if desired_record.ignored: continue elif len(desired_record.included) > 0 and \ target.id not in desired_record.included: self.log.debug('changes: skipping record=%s %s - %s' 'not included ', record.fqdn, record._type, target.id) continue elif target.id in desired_record.excluded: continue except KeyError: if not target.supports(record): self.log.debug('changes: skipping record=%s %s - %s does ' 'not support it', record.fqdn, record._type, target.id) continue # record has been removed self.log.debug('changes: zone=%s, removed record=%s', self, record) changes.append(Delete(record)) else: change = record.changes(desired_record, target) if change: self.log.debug('changes: zone=%s, modified\n' ' existing=%s,\n desired=%s', self, record, desired_record) changes.append(change) else: self.log.debug('changes: zone=%s, n.c. record=%s', self, record) # Find additions, things that are in desired, but missing in ourselves. # This uses set math and our special __hash__ and __cmp__ functions as # well for record in filter(_is_eligible, desired.records - self.records): if record.ignored: continue elif len(record.included) > 0 and \ target.id not in record.included: self.log.debug('changes: skipping record=%s %s - %s not' ' included ', record.fqdn, record._type, target.id) continue elif target.id in record.excluded: self.log.debug('changes: skipping record=%s %s - %s ' 'excluded ', record.fqdn, record._type, target.id) continue if not target.supports(record): self.log.debug('changes: skipping record=%s %s - %s does not ' 'support it', record.fqdn, record._type, target.id) continue self.log.debug('changes: zone=%s, create record=%s', self, record) changes.append(Create(record)) return changes def __repr__(self): return 'Zone<{}>'.format(self.name)