Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zone file import - support missing params. #126

Merged
merged 9 commits into from
Oct 31, 2024
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
19 changes: 15 additions & 4 deletions ns1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#
from .config import Config

version = "0.21.0"
version = "0.22.0"


class NS1:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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(
Expand Down
33 changes: 30 additions & 3 deletions ns1/rest/zones.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
shane-ns1 marked this conversation as resolved.
Show resolved Hide resolved
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,
Expand Down
22 changes: 17 additions & 5 deletions ns1/zones.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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):
Expand Down
128 changes: 128 additions & 0 deletions tests/unit/test_zone.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ns1.rest.zones
import pytest
import os

try: # Python 3.3 +
import unittest.mock as mock
Expand Down Expand Up @@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f-string

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remaining comment, but also a nitpick. So change it if you'd like, but I won't block on it.

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")]
)
Expand Down
Loading