Skip to content

Commit 0e0ac36

Browse files
authored
Merge pull request #126 from ns1/import_to_networks_views
Zone file import - support missing params.
2 parents ac3974a + 27a205a commit 0e0ac36

File tree

5 files changed

+197
-12
lines changed

5 files changed

+197
-12
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 0.22.0 (Oct 29th, 2024)
2+
3+
ENHANCEMENTS:
4+
* Adds support for specifying a list of views when creating zones with or without a provided zone file.
5+
* Adds support for specifying a zone name other than the FQDN when creating zones with or without a provided zone file.
6+
* A specified list of networks for a zone was only applied to zone creation when a zone file was not provided.
7+
18
## 0.21.0 (July 19th, 2024)
29

310
ENHANCEMENTS:

ns1/__init__.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
#
66
from .config import Config
77

8-
version = "0.21.0"
8+
version = "0.22.0"
99

1010

1111
class NS1:
@@ -271,7 +271,13 @@ def searchZone(
271271
return rest_zone.search(query, type, expand, max, callback, errback)
272272

273273
def createZone(
274-
self, zone, zoneFile=None, callback=None, errback=None, **kwargs
274+
self,
275+
zone,
276+
zoneFile=None,
277+
callback=None,
278+
errback=None,
279+
name=None,
280+
**kwargs
275281
):
276282
"""
277283
Create a new zone, and return an associated high level Zone object.
@@ -281,8 +287,9 @@ def createZone(
281287
If zoneFile is specified, upload the specific zone definition file
282288
to populate the zone with.
283289
284-
:param str zone: zone name, like 'example.com'
290+
:param str zone: zone FQDN, like 'example.com'
285291
:param str zoneFile: absolute path of a zone file
292+
:param str name: zone name override, name will be zone FQDN if omitted
286293
:keyword int retry: retry time
287294
:keyword int refresh: refresh ttl
288295
:keyword int expiry: expiry ttl
@@ -295,7 +302,11 @@ def createZone(
295302
zone = ns1.zones.Zone(self.config, zone)
296303

297304
return zone.create(
298-
zoneFile=zoneFile, callback=callback, errback=errback, **kwargs
305+
zoneFile=zoneFile,
306+
name=name,
307+
callback=callback,
308+
errback=errback,
309+
**kwargs
299310
)
300311

301312
def loadRecord(

ns1/rest/zones.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ class Zones(resource.BaseResource):
2121
"link",
2222
"primary_master",
2323
"tags",
24+
"views",
2425
]
2526
BOOL_FIELDS = ["dnssec"]
2627

28+
ZONEFILE_FIELDS = [
29+
"networks",
30+
"views",
31+
]
32+
2733
def _buildBody(self, zone, **kwargs):
2834
body = {}
2935
body["zone"] = zone
@@ -34,19 +40,40 @@ def import_file(
3440
self, zone, zoneFile, callback=None, errback=None, **kwargs
3541
):
3642
files = [("zonefile", (zoneFile, open(zoneFile, "rb"), "text/plain"))]
43+
params = self._buildImportParams(kwargs)
3744
return self._make_request(
3845
"PUT",
39-
"import/zonefile/%s" % (zone),
46+
f"import/zonefile/{zone}",
4047
files=files,
48+
params=params,
4149
callback=callback,
4250
errback=errback,
4351
)
4452

45-
def create(self, zone, callback=None, errback=None, **kwargs):
53+
# Extra import args are specified as query parameters not fields in a JSON object.
54+
def _buildImportParams(self, fields):
55+
params = {}
56+
# Arrays of values should be passed as multiple instances of the same
57+
# parameter but the zonefile API expects parameters containing comma
58+
# seperated values.
59+
if fields.get("networks") is not None:
60+
networks_strs = [str(network) for network in fields["networks"]]
61+
networks_param = ",".join(networks_strs)
62+
params["networks"] = networks_param
63+
if fields.get("views") is not None:
64+
views_param = ",".join(fields["views"])
65+
params["views"] = views_param
66+
if fields.get("name") is not None:
67+
params["name"] = fields.get("name")
68+
return params
69+
70+
def create(self, zone, callback=None, errback=None, name=None, **kwargs):
4671
body = self._buildBody(zone, **kwargs)
72+
if name is None:
73+
name = zone
4774
return self._make_request(
4875
"PUT",
49-
"%s/%s" % (self.ROOT, zone),
76+
f"{self.ROOT}/{name}",
5077
body=body,
5178
callback=callback,
5279
errback=errback,

ns1/zones.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,21 @@ def success(result, *args):
9090
self.zone, callback=success, errback=errback, **kwargs
9191
)
9292

93-
def create(self, zoneFile=None, callback=None, errback=None, **kwargs):
93+
def create(
94+
self, zoneFile=None, callback=None, errback=None, name=None, **kwargs
95+
):
9496
"""
9597
Create a new zone. Pass a list of keywords and their values to
9698
configure. For the list of keywords available for zone configuration,
97-
see :attr:`ns1.rest.zones.Zones.INT_FIELDS` and
99+
see :attr:`ns1.rest.zones.Zones.INT_FIELDS`,
100+
:attr:`ns1.rest.zones.Zones.BOOL_FIELDS` and
98101
:attr:`ns1.rest.zones.Zones.PASSTHRU_FIELDS`
99-
If zoneFile is passed, it should be a zone text file on the local disk
100-
that will be used to populate the created zone file.
102+
Use `name` to pass a unique name for the zone otherwise this will
103+
default to the zone FQDN.
104+
If zoneFile is passed, it should be a zone text file on the local
105+
disk that will be used to populate the created zone file. When a
106+
zoneFile is passed only `name` and
107+
:attr:`ns1.rest.zones.Zones.ZONEFILE_FIELDS` are supported.
101108
"""
102109
if self.data:
103110
raise ZoneException("zone already loaded")
@@ -115,11 +122,16 @@ def success(result, *args):
115122
zoneFile,
116123
callback=success,
117124
errback=errback,
125+
name=name,
118126
**kwargs
119127
)
120128
else:
121129
return self._rest.create(
122-
self.zone, callback=success, errback=errback, **kwargs
130+
self.zone,
131+
callback=success,
132+
errback=errback,
133+
name=name,
134+
**kwargs
123135
)
124136

125137
def __getattr__(self, item):

tests/unit/test_zone.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ns1.rest.zones
22
import pytest
3+
import os
34

45
try: # Python 3.3 +
56
import unittest.mock as mock
@@ -64,6 +65,133 @@ def test_rest_zone_version_list(zones_config, zone, url):
6465
)
6566

6667

68+
@pytest.mark.parametrize(
69+
"zone, name, url",
70+
[
71+
("test.zone", None, "zones/test.zone"),
72+
("test.zone", "test.name", "zones/test.name"),
73+
],
74+
)
75+
def test_rest_zone_create(zones_config, zone, name, url):
76+
z = ns1.rest.zones.Zones(zones_config)
77+
z._make_request = mock.MagicMock()
78+
z.create(zone, name=name)
79+
z._make_request.assert_called_once_with(
80+
"PUT", url, body={"zone": zone}, callback=None, errback=None
81+
)
82+
83+
84+
@pytest.mark.parametrize(
85+
"zone, name, url, params",
86+
[
87+
("test.zone", None, "zones/test.zone", {}),
88+
("test.zone", "test.name", "zones/test.name", {}),
89+
("test.zone", "test2.name", "zones/test2.name", {"networks": [1, 2]}),
90+
(
91+
"test.zone",
92+
"test3.name",
93+
"zones/test3.name",
94+
{"networks": [1, 2], "views": "testview"},
95+
),
96+
(
97+
"test.zone",
98+
"test4.name",
99+
"zones/test4.name",
100+
{"hostmaster": "example:example.com"},
101+
),
102+
],
103+
)
104+
def test_rest_zone_create_with_params(zones_config, zone, name, url, params):
105+
z = ns1.rest.zones.Zones(zones_config)
106+
z._make_request = mock.MagicMock()
107+
z.create(zone, name=name, **params)
108+
body = params
109+
body["zone"] = zone
110+
z._make_request.assert_called_once_with(
111+
"PUT", url, body=body, callback=None, errback=None
112+
)
113+
114+
115+
@pytest.mark.parametrize(
116+
"zone, name, url, networks, views",
117+
[
118+
("test.zone", None, "import/zonefile/test.zone", None, None),
119+
("test.zone", "test.name", "import/zonefile/test.zone", None, None),
120+
(
121+
"test.zone",
122+
"test.name",
123+
"import/zonefile/test.zone",
124+
[1, 2, 99],
125+
None,
126+
),
127+
(
128+
"test.zone",
129+
"test.name",
130+
"import/zonefile/test.zone",
131+
None,
132+
["view1", "view2"],
133+
),
134+
(
135+
"test.zone",
136+
"test.name",
137+
"import/zonefile/test.zone",
138+
[3, 4, 99],
139+
["viewA", "viewB"],
140+
),
141+
],
142+
)
143+
def test_rest_zone_import_file(zones_config, zone, name, url, networks, views):
144+
z = ns1.rest.zones.Zones(zones_config)
145+
z._make_request = mock.MagicMock()
146+
params = {}
147+
networks_strs = None
148+
if networks is not None:
149+
networks_strs = map(str, networks)
150+
params["networks"] = ",".join(networks_strs)
151+
if views is not None:
152+
params["views"] = ",".join(views)
153+
154+
zoneFilePath = "{}/../../examples/importzone.db".format(
155+
os.path.dirname(os.path.abspath(__file__))
156+
)
157+
158+
def cb():
159+
# Should never be printed but provides a function body.
160+
print("Callback invoked!")
161+
162+
# Test without zone name parameter
163+
z.import_file(
164+
zone,
165+
zoneFilePath,
166+
callback=cb,
167+
errback=None,
168+
networks=networks,
169+
views=views,
170+
)
171+
172+
z._make_request.assert_called_once_with(
173+
"PUT", url, files=mock.ANY, callback=cb, errback=None, params=params
174+
)
175+
176+
# Test with new zone name parameter (extra argument)
177+
z._make_request.reset_mock()
178+
179+
if name is not None:
180+
params["name"] = name
181+
z.import_file(
182+
zone,
183+
zoneFilePath,
184+
networks=networks,
185+
views=views,
186+
name=name,
187+
callback=cb,
188+
)
189+
190+
z._make_request.assert_called_once_with(
191+
"PUT", url, files=mock.ANY, callback=cb, errback=None, params=params
192+
)
193+
194+
67195
@pytest.mark.parametrize(
68196
"zone, url", [("test.zone", "zones/test.zone/versions?force=false")]
69197
)

0 commit comments

Comments
 (0)