diff --git a/CHANGELOG.md b/CHANGELOG.md index 287c744..34da163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.22.0 (Oct 29th, 2024) + +ENHANCEMENTS: +* Adds support for specifying a list of views when creating zones with or without a provided zone file. +* Adds support for specifying a zone name other than the FQDN when creating zones with or without a provided zone file. +* A specified list of networks for a zone was only applied to zone creation when a zone file was not provided. + ## 0.21.0 (July 19th, 2024) ENHANCEMENTS: diff --git a/ns1/__init__.py b/ns1/__init__.py index daab292..bf2bbdc 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -5,7 +5,7 @@ # from .config import Config -version = "0.21.0" +version = "0.22.0" class NS1: @@ -271,7 +271,13 @@ def searchZone( return rest_zone.search(query, type, expand, max, callback, errback) def createZone( - self, zone, zoneFile=None, callback=None, errback=None, **kwargs + self, + zone, + zoneFile=None, + callback=None, + errback=None, + name=None, + **kwargs ): """ Create a new zone, and return an associated high level Zone object. @@ -281,8 +287,9 @@ def createZone( If zoneFile is specified, upload the specific zone definition file to populate the zone with. - :param str zone: zone name, like 'example.com' + :param str zone: zone FQDN, like 'example.com' :param str zoneFile: absolute path of a zone file + :param str name: zone name override, name will be zone FQDN if omitted :keyword int retry: retry time :keyword int refresh: refresh ttl :keyword int expiry: expiry ttl @@ -295,7 +302,11 @@ def createZone( zone = ns1.zones.Zone(self.config, zone) return zone.create( - zoneFile=zoneFile, callback=callback, errback=errback, **kwargs + zoneFile=zoneFile, + name=name, + callback=callback, + errback=errback, + **kwargs ) def loadRecord( diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index 8d0d24c..581a30e 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -21,9 +21,15 @@ class Zones(resource.BaseResource): "link", "primary_master", "tags", + "views", ] BOOL_FIELDS = ["dnssec"] + ZONEFILE_FIELDS = [ + "networks", + "views", + ] + def _buildBody(self, zone, **kwargs): body = {} body["zone"] = zone @@ -34,19 +40,40 @@ def import_file( self, zone, zoneFile, callback=None, errback=None, **kwargs ): files = [("zonefile", (zoneFile, open(zoneFile, "rb"), "text/plain"))] + params = self._buildImportParams(kwargs) return self._make_request( "PUT", - "import/zonefile/%s" % (zone), + f"import/zonefile/{zone}", files=files, + params=params, callback=callback, errback=errback, ) - def create(self, zone, callback=None, errback=None, **kwargs): + # Extra import args are specified as query parameters not fields in a JSON object. + def _buildImportParams(self, fields): + params = {} + # Arrays of values should be passed as multiple instances of the same + # parameter but the zonefile API expects parameters containing comma + # seperated values. + if fields.get("networks") is not None: + networks_strs = [str(network) for network in fields["networks"]] + networks_param = ",".join(networks_strs) + params["networks"] = networks_param + if fields.get("views") is not None: + views_param = ",".join(fields["views"]) + params["views"] = views_param + if fields.get("name") is not None: + params["name"] = fields.get("name") + return params + + def create(self, zone, callback=None, errback=None, name=None, **kwargs): body = self._buildBody(zone, **kwargs) + if name is None: + name = zone return self._make_request( "PUT", - "%s/%s" % (self.ROOT, zone), + f"{self.ROOT}/{name}", body=body, callback=callback, errback=errback, diff --git a/ns1/zones.py b/ns1/zones.py index 7b3d4b2..3e5a8d8 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -90,14 +90,21 @@ def success(result, *args): self.zone, callback=success, errback=errback, **kwargs ) - def create(self, zoneFile=None, callback=None, errback=None, **kwargs): + def create( + self, zoneFile=None, callback=None, errback=None, name=None, **kwargs + ): """ Create a new zone. Pass a list of keywords and their values to configure. For the list of keywords available for zone configuration, - see :attr:`ns1.rest.zones.Zones.INT_FIELDS` and + see :attr:`ns1.rest.zones.Zones.INT_FIELDS`, + :attr:`ns1.rest.zones.Zones.BOOL_FIELDS` and :attr:`ns1.rest.zones.Zones.PASSTHRU_FIELDS` - If zoneFile is passed, it should be a zone text file on the local disk - that will be used to populate the created zone file. + Use `name` to pass a unique name for the zone otherwise this will + default to the zone FQDN. + If zoneFile is passed, it should be a zone text file on the local + disk that will be used to populate the created zone file. When a + zoneFile is passed only `name` and + :attr:`ns1.rest.zones.Zones.ZONEFILE_FIELDS` are supported. """ if self.data: raise ZoneException("zone already loaded") @@ -115,11 +122,16 @@ def success(result, *args): zoneFile, callback=success, errback=errback, + name=name, **kwargs ) else: return self._rest.create( - self.zone, callback=success, errback=errback, **kwargs + self.zone, + callback=success, + errback=errback, + name=name, + **kwargs ) def __getattr__(self, item): diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index f162c2a..2786aba 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -1,5 +1,6 @@ import ns1.rest.zones import pytest +import os try: # Python 3.3 + import unittest.mock as mock @@ -64,6 +65,133 @@ def test_rest_zone_version_list(zones_config, zone, url): ) +@pytest.mark.parametrize( + "zone, name, url", + [ + ("test.zone", None, "zones/test.zone"), + ("test.zone", "test.name", "zones/test.name"), + ], +) +def test_rest_zone_create(zones_config, zone, name, url): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + z.create(zone, name=name) + z._make_request.assert_called_once_with( + "PUT", url, body={"zone": zone}, callback=None, errback=None + ) + + +@pytest.mark.parametrize( + "zone, name, url, params", + [ + ("test.zone", None, "zones/test.zone", {}), + ("test.zone", "test.name", "zones/test.name", {}), + ("test.zone", "test2.name", "zones/test2.name", {"networks": [1, 2]}), + ( + "test.zone", + "test3.name", + "zones/test3.name", + {"networks": [1, 2], "views": "testview"}, + ), + ( + "test.zone", + "test4.name", + "zones/test4.name", + {"hostmaster": "example:example.com"}, + ), + ], +) +def test_rest_zone_create_with_params(zones_config, zone, name, url, params): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + z.create(zone, name=name, **params) + body = params + body["zone"] = zone + z._make_request.assert_called_once_with( + "PUT", url, body=body, callback=None, errback=None + ) + + +@pytest.mark.parametrize( + "zone, name, url, networks, views", + [ + ("test.zone", None, "import/zonefile/test.zone", None, None), + ("test.zone", "test.name", "import/zonefile/test.zone", None, None), + ( + "test.zone", + "test.name", + "import/zonefile/test.zone", + [1, 2, 99], + None, + ), + ( + "test.zone", + "test.name", + "import/zonefile/test.zone", + None, + ["view1", "view2"], + ), + ( + "test.zone", + "test.name", + "import/zonefile/test.zone", + [3, 4, 99], + ["viewA", "viewB"], + ), + ], +) +def test_rest_zone_import_file(zones_config, zone, name, url, networks, views): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + params = {} + networks_strs = None + if networks is not None: + networks_strs = map(str, networks) + params["networks"] = ",".join(networks_strs) + if views is not None: + params["views"] = ",".join(views) + + zoneFilePath = "{}/../../examples/importzone.db".format( + os.path.dirname(os.path.abspath(__file__)) + ) + + def cb(): + # Should never be printed but provides a function body. + print("Callback invoked!") + + # Test without zone name parameter + z.import_file( + zone, + zoneFilePath, + callback=cb, + errback=None, + networks=networks, + views=views, + ) + + z._make_request.assert_called_once_with( + "PUT", url, files=mock.ANY, callback=cb, errback=None, params=params + ) + + # Test with new zone name parameter (extra argument) + z._make_request.reset_mock() + + if name is not None: + params["name"] = name + z.import_file( + zone, + zoneFilePath, + networks=networks, + views=views, + name=name, + callback=cb, + ) + + z._make_request.assert_called_once_with( + "PUT", url, files=mock.ANY, callback=cb, errback=None, params=params + ) + + @pytest.mark.parametrize( "zone, url", [("test.zone", "zones/test.zone/versions?force=false")] )