From b14791a066022849a0731dd5c44d061467dd67a8 Mon Sep 17 00:00:00 2001 From: asyncon <53700266+asyncon@users.noreply.github.com> Date: Sat, 18 Jun 2022 15:57:20 +1000 Subject: [PATCH] Support empty and delegated zone creation --- README.md | 1 + octoblox/__init__.py | 61 +++++++++++++++++++++++++++------ tests/config/empty.tests.yaml | 1 + tests/conftest.py | 9 ++++- tests/test_infoblox_provider.py | 23 +++++++++++++ 5 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 tests/config/empty.tests.yaml diff --git a/README.md b/README.md index f167c2c..12f27b9 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ providers: # soa_default_ttl: 3600 # view: default # use_grid_zone_timer: true + # zone_type: zone_delegated ``` ## Alias Record Update Behaviour diff --git a/octoblox/__init__.py b/octoblox/__init__.py index b796487..cd3aad5 100644 --- a/octoblox/__init__.py +++ b/octoblox/__init__.py @@ -3,7 +3,7 @@ from collections import defaultdict from functools import lru_cache from octodns.provider.base import BaseProvider -from octodns.record import Record +from octodns.record import Change, Record # fmt: off single_types = {'ALIAS', 'CNAME', 'PTR'} @@ -52,6 +52,19 @@ # fmt: on +class Create(Change): + """Create Zone Change""" + + CLASS_ORDERING = 0 + + def __init__(self, new): + super().__init__(None, new) + + def __repr__(self, leader=''): + source = self.new.source.id if self.new.source else '' + return f'Create Zone {self.new.fqdn} ({source})' + + class InfoBlox(requests.Session): """Encapsulates all traffic with the InfoBlox WAPI""" @@ -67,6 +80,7 @@ def __init__( log_change=False, new_zone_fields=None, log=None, + zone_type='zone_auth', ): super(InfoBlox, self).__init__() self.fqdn = fqdn @@ -78,7 +92,8 @@ def __init__( self.log_change = log_change self.new_zone_fields = new_zone_fields or {} self.log = log - if not apiver: + self.zone_type = zone_type or 'zone_auth' + if not apiver: # pragma: no branch self.apiver = self.get_api_version() def url(self, url): @@ -90,7 +105,7 @@ def request(self, method, url, **kwargs): ret = super().request(method, self.url(url), **kwargs) try: ret.raise_for_status() - except requests.HTTPError: + except requests.HTTPError: # pragma: no cover self.log.error( 'InfoBlox.request: %d %s %s %r %s', ret.status_code, @@ -128,7 +143,7 @@ def get_zone_fqdn(self, zone): def get_zone(self, zone): return self.get( - 'zone_auth', + self.zone_type, params={ 'fqdn': self.get_zone_fqdn(zone), '_return_fields+': 'soa_default_ttl', @@ -140,7 +155,7 @@ def add_zone(self, zone): fqdn = self.get_zone_fqdn(zone) zone_format = 'IPV6' if ':' in fqdn else 'IPV4' if '/' in fqdn else 'FORWARDING' return self.post( - 'zone_auth', + self.zone_type, json={ 'fqdn': fqdn, 'zone_format': zone_format, @@ -166,7 +181,7 @@ def get_records(self, type, fields, zone, default_ttl, **extra): }, ).json() data = ret['result'] - while 'next_page_id' in ret: + while 'next_page_id' in ret: # pragma: no cover ret = self.get( 'record:{0}'.format(type.lower()), params={'_page_id': ret['next_page_id']}, @@ -252,6 +267,7 @@ def __init__( log_change=False, create_zones=False, new_zone_fields=None, + zone_type='zone_auth', *args, **kwargs, ): @@ -267,6 +283,7 @@ def __init__( log_change, new_zone_fields, self.log, + zone_type, ) self.create_zones = create_zones self.log.debug( @@ -313,8 +330,10 @@ def populate(self, zone, target=False, lenient=False): zone_data = self.conn.get_zone(zone.name) + zone.exists = bool(zone_data) + if not zone_data: - if target and not self.create_zones: + if target and not self.create_zones: # pragma: no cover raise ValueError(f'Zone does not exist in InfoBlox: {zone.name}') return False @@ -339,6 +358,24 @@ def populate(self, zone, target=False, lenient=False): return True + def _extra_changes(self, existing, changes, **kwargs): + if self.create_zones and not existing.exists and not changes: + return [ + Create( + Record.new( + existing, + '', + { + 'type': 'NS', + 'ttl': 3600, + 'values': [existing.name + 'invalid.'], + }, + source=self, + ) + ) + ] + return [] + def _apply_Create(self, zone, change, default_ttl): new = change.new type = new._type @@ -370,7 +407,7 @@ def _apply_Update(self, zone, change, default_ttl): v = {spec[0]: value[:-1], spec[1]: t} self.conn.add_record(type, zone, new.name, v, new.ttl, default_ttl) for t in self.conn.alias_types & {*refs}: - if refs[t][spec[0]] != value: + if refs[t][spec[0]] != value: # pragma: no branch v = {spec[0]: value[:-1], spec[1]: t} self.conn.mod_record(type, refs[t], v, new.ttl, default_ttl) self.conn.del_record( @@ -379,7 +416,7 @@ def _apply_Update(self, zone, change, default_ttl): elif type in single_types: self.conn.mod_record(type, ext.refs[0], value, new.ttl, default_ttl) elif value in evalues: - if update: + if update: # pragma: no branch self.conn.mod_record( type, ext.refs[evalues.index(value)], @@ -387,7 +424,7 @@ def _apply_Update(self, zone, change, default_ttl): new.ttl, default_ttl, ) - else: + else: # pragma: no cover self.conn.add_record(type, zone, new.name, value, new.ttl, default_ttl) if type not in single_types: self.conn.del_record( @@ -401,12 +438,14 @@ def _apply(self, plan): zone_data = self.conn.get_zone(zone) if not zone_data: - if not self.create_zones: + if not self.create_zones: # pragma: no cover raise ValueError(f'Zone does not exist in InfoBlox: {zone}') zone_data = [self.conn.add_zone(zone)] default_ttl = zone_data[0].get('soa_default_ttl', 3600) for change in plan.changes: + if isinstance(change, Create): + continue class_name = change.__class__.__name__ getattr(self, f'_apply_{class_name}')(zone[:-1], change, default_ttl) diff --git a/tests/config/empty.tests.yaml b/tests/config/empty.tests.yaml new file mode 100644 index 0000000..2fbf0ff --- /dev/null +++ b/tests/config/empty.tests.yaml @@ -0,0 +1 @@ +--- {} diff --git a/tests/conftest.py b/tests/conftest.py index 5dcbf9c..431fcdf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,11 @@ def new_zone_name(): return 'create.tests.' +@pytest.fixture +def empty_zone_name(): + return 'empty.tests.' + + @pytest.fixture def new_ipv4_zone(): return '12.11.10.in-addr.arpa.' @@ -59,7 +64,7 @@ def schema(): @pytest.fixture -def zones(zone_name, new_zone_name, new_ipv4_cidr, new_ipv6_cidr): +def zones(zone_name, new_zone_name, empty_zone_name, new_ipv4_cidr, new_ipv6_cidr): return { f'/wapi/v1.0/zone_auth?fqdn={zone_name[:-1]}': [ { @@ -70,6 +75,8 @@ def zones(zone_name, new_zone_name, new_ipv4_cidr, new_ipv6_cidr): } ], f'/wapi/v1.0/zone_auth?fqdn={new_zone_name[:-1]}': [], + f'/wapi/v1.0/zone_auth?fqdn={empty_zone_name[:-1]}': [], + f'/wapi/v1.0/zone_delegated?fqdn={empty_zone_name[:-1]}': [], f'/wapi/v1.0/zone_auth?fqdn={quote_plus(new_ipv4_cidr)}': [], f'/wapi/v1.0/zone_auth?fqdn={quote_plus(new_ipv6_cidr)}': [], } diff --git a/tests/test_infoblox_provider.py b/tests/test_infoblox_provider.py index e3fdae3..41a68f4 100644 --- a/tests/test_infoblox_provider.py +++ b/tests/test_infoblox_provider.py @@ -28,6 +28,29 @@ def test_zone_creation(provider, new_zone_name): provider.apply(plan) +def test_empty_zone_creation(provider, empty_zone_name): + expected = Zone(empty_zone_name, []) + source = YamlProvider('test', os.path.join(os.path.dirname(__file__), 'config')) + source.populate(expected) + assert len(expected.records) == 0 + zone = Zone(empty_zone_name, []) + provider.populate(zone) + plan = provider.plan(expected) + provider.apply(plan) + + +def test_delegated_zone_creation(provider, empty_zone_name): + provider.conn.zone_type = 'zone_delegated' + expected = Zone(empty_zone_name, []) + source = YamlProvider('test', os.path.join(os.path.dirname(__file__), 'config')) + source.populate(expected) + assert len(expected.records) == 0 + zone = Zone(empty_zone_name, []) + provider.populate(zone) + plan = provider.plan(expected) + provider.apply(plan) + + def test_ipv4_zone(provider, new_ipv4_zone): expected = Zone(new_ipv4_zone, []) source = YamlProvider('test', os.path.join(os.path.dirname(__file__), 'config'))