diff --git a/designateclient/functionaltests/client.py b/designateclient/functionaltests/client.py index 4d1d0b0a..6edf3773 100644 --- a/designateclient/functionaltests/client.py +++ b/designateclient/functionaltests/client.py @@ -348,9 +348,28 @@ class BlacklistCommands(object): return self.parsed_cmd(cmd, FieldValueModel, *args, **kwargs) +class SharedZoneCommands(object): + + def shared_zone_show(self, zone_id, shared_zone_id, *args, **kwargs): + cmd = 'zone share show {0} {1}'.format(zone_id, shared_zone_id) + return self.parsed_cmd(cmd, FieldValueModel, *args, **kwargs) + + def shared_zone_list(self, zone_id, *args, **kwargs): + cmd = 'zone share list {0}'.format(zone_id) + return self.parsed_cmd(cmd, ListModel, *args, **kwargs) + + def share_zone(self, zone_id, target_project_id, *args, **kwargs): + cmd = 'zone share create {0} {1}'.format(zone_id, target_project_id) + return self.parsed_cmd(cmd, FieldValueModel, *args, **kwargs) + + def unshare_zone(self, zone_id, shared_zone_id, *args, **kwargs): + cmd = 'zone share delete {0} {1}'.format(zone_id, shared_zone_id) + return self.parsed_cmd(cmd, FieldValueModel, *args, **kwargs) + + class DesignateCLI(base.CLIClient, ZoneCommands, ZoneTransferCommands, ZoneExportCommands, ZoneImportCommands, RecordsetCommands, - TLDCommands, BlacklistCommands): + TLDCommands, BlacklistCommands, SharedZoneCommands): # instantiate this once to minimize requests to keystone _CLIENTS = None diff --git a/designateclient/functionaltests/v2/fixtures.py b/designateclient/functionaltests/v2/fixtures.py index cc5a83b5..c72a18b7 100644 --- a/designateclient/functionaltests/v2/fixtures.py +++ b/designateclient/functionaltests/v2/fixtures.py @@ -228,3 +228,25 @@ class BlacklistFixture(BaseFixture): client.zone_blacklist_delete(blacklist_id) except CommandFailed: pass + + +class SharedZoneFixture(BaseFixture): + """See DesignateCLI.recordset_create for __init__ args""" + + def __init__(self, zone, *args, **kwargs): + super(SharedZoneFixture, self).__init__(*args, **kwargs) + self.zone = zone + + def _setUp(self): + super(SharedZoneFixture, self)._setUp() + self.zone_share = self.client.zone_share(zone_id=self.zone.id, + *self.args, **self.kwargs) + self.addCleanup(self.cleanup_shared_zone, self.client, self.zone.id, + self.zone_share.id) + + @classmethod + def cleanup_shared_zone(cls, client, zone_id, shared_zone_id): + try: + client.unshare_zone(zone_id, shared_zone_id) + except CommandFailed: + pass diff --git a/designateclient/functionaltests/v2/test_shared_zone.py b/designateclient/functionaltests/v2/test_shared_zone.py new file mode 100644 index 00000000..0e8b3928 --- /dev/null +++ b/designateclient/functionaltests/v2/test_shared_zone.py @@ -0,0 +1,73 @@ +""" + Copyright 2020 Cloudification GmbH. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from designateclient.functionaltests.base import BaseDesignateTest +from designateclient.functionaltests.client import DesignateCLI +from designateclient.functionaltests.datagen import random_zone_name +from designateclient.functionaltests.v2.fixtures import SharedZoneFixture +from designateclient.functionaltests.v2.fixtures import ZoneFixture + + +class TestSharedZone(BaseDesignateTest): + + def setUp(self): + super(TestSharedZone, self).setUp() + self.ensure_tld_exists('com') + fixture = self.useFixture(ZoneFixture( + name=random_zone_name(), + email='test@example.com', + )) + self.zone = fixture.zone + self.target_client = DesignateCLI.as_user('alt') + + def test_list_shared_zones(self): + shared_zone = self.useFixture(SharedZoneFixture( + zone_id=self.zone.id, + target_tenant_id=self.target_client.project_id + )).zone_share + + shared_zones = self.clients.shared_zone_list(self.zone.id) + self.assertGreater(len(shared_zones), 0) + self.assertTrue(self._is_entity_in_list(shared_zone, shared_zones)) + + def test_share_and_show_shared_zone(self): + shared_zone = self.useFixture(SharedZoneFixture( + zone_id=self.zone.id, + target_tenant_id=self.target_client.project_id + )).zone_share + + fetched_shared_zone = self.clients.shared_zone_show(self.zone.id, + shared_zone.id) + + self.assertEqual( + shared_zone.created_at, fetched_shared_zone.created_at) + self.assertEqual(shared_zone.id, fetched_shared_zone.id) + self.assertEqual( + shared_zone.project_id, fetched_shared_zone.project_id) + self.assertEqual(shared_zone.zone_id, fetched_shared_zone.zone_id) + + def test_unshare_zone(self): + shared_zone = self.useFixture(SharedZoneFixture( + zone_id=self.zone.id, + target_tenant_id=self.target_client.project_id + )).zone_share + + shared_zones = self.clients.shared_zone_list(self.zone.id) + self.assertTrue(self._is_entity_in_list(shared_zone, shared_zones)) + + self.clients.unshare_zone(self.zone.id, shared_zone.id) + + shared_zones = self.clients.shared_zone_list(self.zone.id) + self.assertFalse(self._is_entity_in_list(shared_zone, shared_zones)) diff --git a/designateclient/tests/v2/test_zones.py b/designateclient/tests/v2/test_zones.py index 5470be71..63ee81e3 100644 --- a/designateclient/tests/v2/test_zones.py +++ b/designateclient/tests/v2/test_zones.py @@ -116,7 +116,19 @@ class TestZones(v2.APIV2TestCase, v2.CrudMixin): self.stub_entity("DELETE", id=ref["id"]) self.client.zones.delete(ref["id"]) + self.assertRequestBodyIs(None) + self.assertRequestHeaderEqual('X-Designate-Delete-Shares', None) + + def test_delete_with_delete_shares(self): + ref = self.new_ref() + + self.stub_entity("DELETE", id=ref["id"]) + + self.client.zones.delete(ref["id"], delete_shares=True) + + self.assertRequestBodyIs(None) + self.assertRequestHeaderEqual('X-Designate-Delete-Shares', 'true') def test_task_abandon(self): ref = self.new_ref() @@ -380,3 +392,73 @@ class TestZoneImports(v2.APIV2TestCase, v2.CrudMixin): self.client.zone_imports.delete(ref["id"]) self.assertRequestBodyIs(None) + + +class TestZoneShared(v2.APIV2TestCase, v2.CrudMixin): + def setUp(self): + super(TestZoneShared, self).setUp() + self.zone_id = str(uuid.uuid4()) + self.target_project_id = str(uuid.uuid4()) + self.project_id = str(uuid.uuid4()) + self.created_at = time.strftime("%c") + self.updated_at = time.strftime("%c") + + def new_ref(self, **kwargs): + ref = super(TestZoneShared, self).new_ref(**kwargs) + ref.setdefault("zone_id", self.zone_id) + ref.setdefault("target_project_id", self.target_project_id) + ref.setdefault("project_id", self.project_id) + ref.setdefault("created_at", self.created_at) + ref.setdefault("updated_at", self.updated_at) + return ref + + def test_share_a_zone(self): + json_body = {"target_project_id": self.target_project_id} + + expected = self.new_ref() + + self.stub_entity('POST', parts=['zones', self.zone_id, 'shares'], + entity=expected, json=json_body) + + response = self.client.zone_share.create(self.zone_id, + self.target_project_id) + + self.assertRequestBodyIs(json=json_body) + self.assertEqual(expected, response) + + def test_get_zone_share(self): + expected = self.new_ref() + + parts = ["zones", self.zone_id, "shares"] + self.stub_entity("GET", parts=parts, entity=expected, + id=expected["id"]) + + response = self.client.zone_share.get(self.zone_id, expected["id"]) + + self.assertRequestBodyIs(None) + self.assertEqual(expected, response) + + def test_list_zone_shares(self): + items = [ + self.new_ref(), + self.new_ref() + ] + + parts = ["zones", self.zone_id, "shares"] + self.stub_entity('GET', parts=parts, entity={"shared_zones": items}) + + listed = self.client.zone_share.list(self.zone_id) + + self.assertList(items, listed) + self.assertQueryStringIs("") + + def test_delete_zone_share(self): + ref = self.new_ref() + + parts = ["zones", self.zone_id, "shares", ref["id"]] + self.stub_url('DELETE', parts=parts) + + response = self.client.zone_share.delete(self.zone_id, ref["id"]) + + self.assertRequestBodyIs(None) + self.assertEqual('', response) diff --git a/designateclient/v2/cli/zones.py b/designateclient/v2/cli/zones.py index f3262329..24f64de2 100644 --- a/designateclient/v2/cli/zones.py +++ b/designateclient/v2/cli/zones.py @@ -241,6 +241,10 @@ class DeleteZoneCommand(command.ShowOne): parser.add_argument('id', help="Zone ID") + parser.add_argument('--delete-shares', default=False, + action='store_true', + help='Delete existing zone shares. Default: False') + common.add_all_common_options(parser) common.add_hard_delete_option(parser) @@ -250,7 +254,13 @@ class DeleteZoneCommand(command.ShowOne): client = self.app.client_manager.dns common.set_all_common_headers(client, parsed_args) - data = client.zones.delete(parsed_args.id) + delete_shares = False + if (hasattr(parsed_args, 'delete_shares') and + parsed_args.delete_shares is not None and + isinstance(parsed_args.delete_shares, bool)): + delete_shares = parsed_args.delete_shares + + data = client.zones.delete(parsed_args.id, delete_shares=delete_shares) LOG.info('Zone %s was deleted', parsed_args.id) _format_zone(data) @@ -724,3 +734,124 @@ class DeleteZoneImportCommand(command.Command): client.zone_imports.delete(parsed_args.zone_import_id) LOG.info('Zone Import %s was deleted', parsed_args.zone_import_id) + + +class ShareZoneCommand(command.ShowOne): + """Share a Zone""" + + def get_parser(self, prog_name): + parser = super(ShareZoneCommand, self).get_parser( + prog_name) + + common.add_all_common_options(parser) + + parser.add_argument('zone', help='The zone name or ID to share.') + parser.add_argument('target_project_id', + help='Target project ID to share the zone with.') + + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.dns + common.set_all_common_headers(client, parsed_args) + + data = client.zone_share.create( + parsed_args.zone, + parsed_args.target_project_id + ) + + LOG.info('Zone %s was shared', data['id']) + + data.pop('links', None) + + return self.dict2columns(data) + + +class ListSharedZonesCommand(command.Lister): + """List Zone Shares""" + + columns = [ + 'id', + 'zone_id', + 'target_project_id', + ] + + def get_parser(self, prog_name): + parser = super(ListSharedZonesCommand, self).get_parser( + prog_name) + + common.add_all_common_options(parser) + + parser.add_argument('zone', help='The zone name or ID to share.') + + parser.add_argument('--target-project-id', + help='The target project ID to filter on.', + required=False) + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.dns + common.set_all_common_headers(client, parsed_args) + + criterion = {} + if parsed_args.target_project_id is not None: + criterion['target_project_id'] = parsed_args.target_project_id + + data = get_all(client.zone_share.list, criterion=criterion, + args=[parsed_args.zone]) + + cols = list(self.columns) + + if client.session.all_projects: + cols.insert(1, 'project_id') + + return cols, (utils.get_item_properties(s, cols) for s in data) + + +class ShowSharedZoneCommand(command.ShowOne): + """Show Zone Share Details""" + + def get_parser(self, prog_name): + parser = super(ShowSharedZoneCommand, self).get_parser(prog_name) + + parser.add_argument('zone', help='The zone name or ID to share.') + parser.add_argument('shared_zone_id', + help='The zone share ID to show.') + + common.add_all_common_options(parser) + + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.dns + common.set_all_common_headers(client, parsed_args) + + data = client.zone_share.get(parsed_args.zone, + parsed_args.shared_zone_id) + data.pop('links', None) + + return self.dict2columns(data) + + +class DeleteSharedZoneCommand(command.Command): + """Delete a Zone Share""" + + def get_parser(self, prog_name): + parser = super(DeleteSharedZoneCommand, self).get_parser( + prog_name) + + parser.add_argument('zone', help='The zone name or ID to share.') + parser.add_argument('shared_zone_id', + help='The zone share ID to delete.') + + common.add_all_common_options(parser) + + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.dns + common.set_all_common_headers(client, parsed_args) + + client.zone_share.delete(parsed_args.zone, parsed_args.shared_zone_id) + + LOG.info('Shared Zone %s was deleted', parsed_args.shared_zone_id) diff --git a/designateclient/v2/client.py b/designateclient/v2/client.py index 44635068..053275ce 100644 --- a/designateclient/v2/client.py +++ b/designateclient/v2/client.py @@ -29,6 +29,7 @@ from designateclient.v2.tsigkeys import TSIGKeysController from designateclient.v2.zones import ZoneController from designateclient.v2.zones import ZoneExportsController from designateclient.v2.zones import ZoneImportsController +from designateclient.v2.zones import ZoneShareController from designateclient.v2.zones import ZoneTransfersController from designateclient import version from oslo_utils import importutils @@ -151,6 +152,7 @@ class Client(object): self.zone_transfers = ZoneTransfersController(self) self.zone_exports = ZoneExportsController(self) self.zone_imports = ZoneImportsController(self) + self.zone_share = ZoneShareController(self) self.pools = PoolController(self) self.quotas = QuotasController(self) self.tsigkeys = TSIGKeysController(self) diff --git a/designateclient/v2/zones.py b/designateclient/v2/zones.py index 7e862a79..209d4f08 100644 --- a/designateclient/v2/zones.py +++ b/designateclient/v2/zones.py @@ -62,12 +62,18 @@ class ZoneController(V2Controller): return self._patch(url, data=values) - def delete(self, zone): + def delete(self, zone, delete_shares=False): zone = v2_utils.resolve_by_name(self.list, zone) url = self.build_url('/zones/%s' % zone) - return self._delete(url) + if delete_shares: + headers = {'X-Designate-Delete-Shares': 'true'} + _resp, body = self.client.session.delete(url, headers=headers) + else: + _resp, body = self.client.session.delete(url) + + return body def abandon(self, zone): zone = v2_utils.resolve_by_name(self.list, zone) @@ -166,3 +172,29 @@ class ZoneImportsController(V2Controller): def delete(self, zone_import_id): return self._delete('/zones/tasks/imports/%s' % zone_import_id) + + +class ZoneShareController(V2Controller): + def create(self, zone, target_project_id): + zone_id = v2_utils.resolve_by_name(self.client.zones.list, zone) + + data = {"target_project_id": target_project_id} + + return self._post(f'/zones/{zone_id}/shares', data=data) + + def list(self, zone, criterion=None, marker=None, limit=None): + zone_id = v2_utils.resolve_by_name(self.client.zones.list, zone) + url = self.build_url(f'/zones/{zone_id}/shares', + criterion, marker, limit) + + return self._get(url, response_key='shared_zones') + + def delete(self, zone, shared_zone_id): + zone_id = v2_utils.resolve_by_name(self.client.zones.list, zone) + + return self._delete(f'/zones/{zone_id}/shares/{shared_zone_id}') + + def get(self, zone, shared_zone_id): + zone_id = v2_utils.resolve_by_name(self.client.zones.list, zone) + + return self._get(f'/zones/{zone_id}/shares/{shared_zone_id}') diff --git a/releasenotes/notes/Add-shared-zones-support-4be565f3d1c6356c.yaml b/releasenotes/notes/Add-shared-zones-support-4be565f3d1c6356c.yaml new file mode 100644 index 00000000..7d810d19 --- /dev/null +++ b/releasenotes/notes/Add-shared-zones-support-4be565f3d1c6356c.yaml @@ -0,0 +1,6 @@ +--- +features: + - Adds zone share commands to support sharing zones with additional projects. + - Adds a ``--delete-shares`` option to zone delete to delete existing zone + shares along with the zone. Without this option, you cannot delete a zone + that has been shared with other projects. diff --git a/setup.cfg b/setup.cfg index f6e2414b..86429223 100644 --- a/setup.cfg +++ b/setup.cfg @@ -74,6 +74,11 @@ openstack.dns.v2 = zone_transfer_accept_list = designateclient.v2.cli.zones:ListTransferAcceptsCommand zone_transfer_accept_show = designateclient.v2.cli.zones:ShowTransferAcceptCommand + zone_share_create = designateclient.v2.cli.zones:ShareZoneCommand + zone_share_list = designateclient.v2.cli.zones:ListSharedZonesCommand + zone_share_show = designateclient.v2.cli.zones:ShowSharedZoneCommand + zone_share_delete = designateclient.v2.cli.zones:DeleteSharedZoneCommand + recordset_create = designateclient.v2.cli.recordsets:CreateRecordSetCommand recordset_list = designateclient.v2.cli.recordsets:ListRecordSetsCommand recordset_show = designateclient.v2.cli.recordsets:ShowRecordSetCommand