diff --git a/CHANGELOG.md b/CHANGELOG.md index 71dfa6e..ef2ba06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.28.0 (TBD) + +ENHANCEMENTS: +* Add zone export functionality to export zones in BIND format for backup/migration + ## 0.27.1 (December 3rd, 2025) FIXED: diff --git a/examples/zone-export.py b/examples/zone-export.py new file mode 100644 index 0000000..5c100c2 --- /dev/null +++ b/examples/zone-export.py @@ -0,0 +1,37 @@ +# +# Copyright IBM Corp. 2026 +# +# License under The MIT License (MIT). See LICENSE in project root. +# + +from ns1 import NS1 + +# NS1 will use config in ~/.nsone by default +api = NS1() + +# to specify an apikey here instead, use: +# api = NS1(apiKey='<>') + +# to load an alternate configuration file: +# api = NS1(configFile='/etc/ns1/api.json') + +# Define the zone to export +zone_name = "example.com" + +# Export a zone to BIND format +# The export() method will: +# 1. Initiate the export job +# 2. Poll the status until complete or failed +# 3. Download and return the zone file content +zone = api.loadZone(zone_name) + +print(f"Exporting zone {zone_name}...") +zone_file = zone.export() +print("Export complete!") +print(zone_file) + +# Save to a file +output_file = f"{zone_name}.txt" +with open(output_file, "w") as f: + f.write(zone_file) +print(f"Zone file saved to {output_file}") diff --git a/ns1/__init__.py b/ns1/__init__.py index 9dbfb06..f6eedc2 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -1,11 +1,11 @@ # -# Copyright (c) 2014, 2025 NSONE, Inc. +# Copyright IBM Corp. 2014, 2026 # # License under The MIT License (MIT). See LICENSE in project root. # from .config import Config -version = "0.27.1" +version = "0.28.0" class NS1: diff --git a/ns1/rest/zones.py b/ns1/rest/zones.py index 07f01fe..daf319f 100644 --- a/ns1/rest/zones.py +++ b/ns1/rest/zones.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2014 NSONE, Inc. +# Copyright IBM Corp. 2014, 2026 # # License under The MIT License (MIT). See LICENSE in project root. # @@ -188,6 +188,50 @@ def delete_version(self, zone, version_id, callback=None, errback=None): errback=errback, ) + def initiate_zonefile_export(self, zone, callback=None, errback=None): + """ + Initiate zone export job. + + :param str zone: zone name + :return: export status response + """ + return self._make_request( + "PUT", + f"export/zonefile/{zone}", + body={}, + callback=callback, + errback=errback, + ) + + def status_zonefile_export(self, zone, callback=None, errback=None): + """ + Check zone export status. + + :param str zone: zone name + :return: export status response + """ + return self._make_request( + "GET", + f"export/zonefile/{zone}/status", + callback=callback, + errback=errback, + ) + + def get_zonefile_export(self, zone, callback=None, errback=None): + """ + Download the exported zone file in BIND-compatible format. + + :param str zone: zone name + :return: zone file content as string + """ + return self._make_request( + "GET", + f"export/zonefile/{zone}", + callback=callback, + errback=errback, + skip_json_parsing=True, + ) + # successive pages just extend the list of zones def zone_list_pagination(curr_json, next_json): diff --git a/ns1/zones.py b/ns1/zones.py index 3e5a8d8..174d38c 100644 --- a/ns1/zones.py +++ b/ns1/zones.py @@ -1,8 +1,10 @@ # -# Copyright (c) 2014 NSONE, Inc. +# Copyright IBM Corp. 2014, 2026 # # License under The MIT License (MIT). See LICENSE in project root. # +import time + from ns1.rest.zones import Zones from ns1.records import Record from ns1.rest.stats import Stats @@ -123,7 +125,7 @@ def success(result, *args): callback=success, errback=errback, name=name, - **kwargs + **kwargs, ) else: return self._rest.create( @@ -131,7 +133,7 @@ def success(result, *args): callback=success, errback=errback, name=name, - **kwargs + **kwargs, ) def __getattr__(self, item): @@ -169,7 +171,7 @@ def linkRecord( rtype, callback=None, errback=None, - **kwargs + **kwargs, ): """ Create a new linked record in this zone. These records use the @@ -194,7 +196,7 @@ def linkRecord( link=existing_domain, callback=callback, errback=errback, - **kwargs + **kwargs, ) def cloneRecord( @@ -287,3 +289,51 @@ def usage(self, callback=None, errback=None, **kwargs): return stats.usage( zone=self.zone, callback=callback, errback=errback, **kwargs ) + + def export( + self, callback=None, errback=None, timeout=300, poll_interval=2 + ): + """ + Export zone as a BIND-compatible zone file. + + This method initiates the export, polls the status until complete or failed, + and downloads the zone file. + + :param callback: optional callback + :param errback: optional error callback + :param int timeout: maximum time to wait for export completion in seconds (default: 300) + :param int poll_interval: time between status checks in seconds (default: 2) + :return: zone file content as string + :raises ZoneException: if export fails or times out + """ + # Initiate the export + init_response = self._rest.initiate_zonefile_export(self.zone) + if not init_response or init_response.get("status") == "FAILED": + error_msg = init_response.get( + "message", "Failed to initiate export" + ) + raise ZoneException(f"Zone export initiation failed: {error_msg}") + + # Poll the status until complete or failed + start_time = time.time() + while True: + if time.time() - start_time > timeout: + raise ZoneException( + f"Zone export timed out after {timeout} seconds" + ) + + status_response = self._rest.status_zonefile_export(self.zone) + status = status_response.get("status") + + if status == "COMPLETED": + break + elif status == "FAILED": + error_msg = status_response.get("message", "Unknown error") + raise ZoneException(f"Zone export failed: {error_msg}") + + time.sleep(poll_interval) + + # Download the zone file + return self._rest.get_zonefile_export( + self.zone, callback=callback, errback=errback + ) diff --git a/tests/unit/test_zone.py b/tests/unit/test_zone.py index 5dae71d..2b5e0a2 100644 --- a/tests/unit/test_zone.py +++ b/tests/unit/test_zone.py @@ -1,3 +1,9 @@ +# +# Copyright IBM Corp. 2014, 2026 +# +# License under The MIT License (MIT). See LICENSE in project root. +# + import ns1.rest.zones import pytest import os @@ -251,3 +257,50 @@ def test_rest_zone_buildbody(zones_config): "tags": {"foo": "bar", "hai": "bai"}, } assert z._buildBody(zone, **kwargs) == body + + +@pytest.mark.parametrize( + "zone, url", [("test.zone", "export/zonefile/test.zone")] +) +def test_rest_zone_get_zonefile_export(zones_config, zone, url): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + z.get_zonefile_export(zone) + z._make_request.assert_called_once_with( + "GET", + url, + callback=None, + errback=None, + skip_json_parsing=True, + ) + + +@pytest.mark.parametrize( + "zone, url", [("test.zone", "export/zonefile/test.zone")] +) +def test_rest_zone_initiate_zonefile_export(zones_config, zone, url): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + z.initiate_zonefile_export(zone) + z._make_request.assert_called_once_with( + "PUT", + url, + body={}, + callback=None, + errback=None, + ) + + +@pytest.mark.parametrize( + "zone, url", [("test.zone", "export/zonefile/test.zone/status")] +) +def test_rest_zone_status_zonefile_export(zones_config, zone, url): + z = ns1.rest.zones.Zones(zones_config) + z._make_request = mock.MagicMock() + z.status_zonefile_export(zone) + z._make_request.assert_called_once_with( + "GET", + url, + callback=None, + errback=None, + )