From ecfc04806100c6c0363f6ef872fb748608982d44 Mon Sep 17 00:00:00 2001 From: Jan Horstmann Date: Thu, 15 Jan 2026 16:10:37 +0100 Subject: [PATCH] Allow management of project's default volume type Allow optional specification and mangement of a project's default volume type. Signed-off-by: Jan Horstmann --- openstack_project_manager/create.py | 12 ++ openstack_project_manager/manage.py | 93 ++++++++++ test/unit/test_create.py | 4 + test/unit/test_manage.py | 261 +++++++++++++++++++++++++++- 4 files changed, 363 insertions(+), 7 deletions(-) diff --git a/openstack_project_manager/create.py b/openstack_project_manager/create.py index f5550c7..44c1eb7 100755 --- a/openstack_project_manager/create.py +++ b/openstack_project_manager/create.py @@ -166,6 +166,12 @@ def run( service_network_cidr: Annotated[ str, typer.Option("--service-network-cidr", help="Service network CIDR") ] = "", + default_volume_type: Annotated[ + Optional[str], + typer.Option( + "--default-volume-type", help="Project-specific default volume type" + ), + ] = None, ) -> None: # Connect to the OpenStack environment @@ -305,6 +311,12 @@ def run( # Set other parameters of the project keystone.projects.update(project=project.id, owner=owner) + # Set default volume type of the project + if default_volume_type: + keystone.projects.update( + project=project.id, default_volume_type=default_volume_type + ) + # The network resources of the project should be created automatically if managed_network_resources: keystone.projects.update( diff --git a/openstack_project_manager/manage.py b/openstack_project_manager/manage.py index 6838c09..af0c762 100755 --- a/openstack_project_manager/manage.py +++ b/openstack_project_manager/manage.py @@ -530,6 +530,84 @@ def manage_private_volumetypes( configuration.os_cloud.block_storage.add_type_access(volume_type, project.id) +def manage_default_volume_type( + configuration: Configuration, + project: openstack.identity.v3.project.Project, + domain: openstack.identity.v3.domain.Domain, + classes: str, +) -> None: + logger.info(f"{project.name} - managing default volume type") + if "quotaclass" in project: + quotaclass = get_quotaclass(classes, project.quotaclass) + else: + logger.warning(f"{project.name} - quotaclass not set --> use default") + if domain.name.startswith("ok"): + quotaclass = get_quotaclass(classes, "okeanos") + else: + quotaclass = get_quotaclass(classes, "basic") + + if "default_volume_type" in project and project.default_volume_type: + # NOTE: It is impossible to unset a project property, so we need to make sure it actually contains a value + default_volume_type_name_or_id = project.default_volume_type + elif quotaclass and "default_volume_type" in quotaclass: + default_volume_type_name_or_id = quotaclass["default_volume_type"] + else: + default_volume_type_name_or_id = None + + if default_volume_type_name_or_id: + # NOTE: Find declared volume type in public and private types (find_type() does not search private types) + default_volume_types = [] + for is_public in [True, False]: + default_volume_types += [ + volume_type + for volume_type in configuration.os_cloud.block_storage.types( + is_public=is_public + ) + if default_volume_type_name_or_id == volume_type.id + or default_volume_type_name_or_id == volume_type.name + ] + + if not default_volume_types: + logger.error( + f"{project.name} - default volume type {default_volume_type_name_or_id} not found" + ) + return + elif len(default_volume_types) > 1: + logger.error( + f"{project.name} - default volume type {default_volume_type_name_or_id} not unique, please use ID" + ) + return + else: + default_volume_type = default_volume_types[0] + + else: + default_volume_type = None + + try: + current_default_type = configuration.os_cloud.block_storage.show_default_type( + project + ) + except openstack.exceptions.NotFoundException: + current_default_type = None + + if not default_volume_type and not current_default_type: + return + elif not default_volume_type and current_default_type: + logger.info( + f"{project.name} - Unsetting default volume type {current_default_type.volume_type_id}" + ) + configuration.os_cloud.block_storage.unset_default_type(project) + elif ( + default_volume_type and not current_default_type + ) or default_volume_type.id != current_default_type.volume_type_id: + logger.info( + f"{project.name} - Setting default volume type {default_volume_type.id} ({default_volume_type.name})" + ) + configuration.os_cloud.block_storage.set_default_type( + project, default_volume_type + ) + + def check_flavors( configuration: Configuration, project: openstack.identity.v3.project.Project, @@ -1224,6 +1302,7 @@ def process_project( manage_endpoints: bool, manage_homeprojects: bool, manage_privatevolumetypes: bool, + manage_defaultvolumetype: bool, manage_privateflavors: bool, ) -> None: @@ -1275,6 +1354,9 @@ def process_project( if manage_privatevolumetypes: manage_private_volumetypes(configuration, project, domain) + if manage_defaultvolumetype: + manage_default_volume_type(configuration, project, domain, classes) + check_flavors(configuration, project, domain, classes) if manage_privateflavors: @@ -1331,6 +1413,13 @@ def run( help="Manage private volume types", ), ] = True, + manage_defaultvolumetype: Annotated[ + bool, + typer.Option( + "--manage-defaultvolumetype/--nomanage-defaultvolumetype", + help="Manage default volume type", + ), + ] = True, manage_privateflavors: Annotated[ bool, typer.Option( @@ -1384,6 +1473,7 @@ def run( manage_endpoints, manage_homeprojects, manage_privatevolumetypes, + manage_defaultvolumetype, manage_privateflavors, ) @@ -1419,6 +1509,7 @@ def run( manage_endpoints, manage_homeprojects, manage_privatevolumetypes, + manage_defaultvolumetype, manage_privateflavors, ) @@ -1444,6 +1535,7 @@ def run( manage_endpoints, manage_homeprojects, manage_privatevolumetypes, + manage_defaultvolumetype, manage_privateflavors, ) @@ -1474,6 +1566,7 @@ def run( manage_endpoints, manage_homeprojects, manage_privatevolumetypes, + manage_defaultvolumetype, manage_privateflavors, ) diff --git a/test/unit/test_create.py b/test/unit/test_create.py index be4c0be..6703df0 100644 --- a/test/unit/test_create.py +++ b/test/unit/test_create.py @@ -347,6 +347,7 @@ def test_cli_13(self): "--name=othername", "--owner=otherowner", "--public-network=otherpublic", + "--default-volume-type=othertype", ], ) self.assertEqual(result.exit_code, 0, (result, result.stdout)) @@ -366,6 +367,9 @@ def test_cli_13(self): self.mock_os_keystone.projects.update.assert_any_call( project=9012, owner="otherowner" ) + self.mock_os_keystone.projects.update.assert_any_call( + project=9012, default_volume_type="othertype" + ) self.mock_os_cloud.identity.find_user.assert_called_once_with( "otherdomain-admin", domain_id=1234 ) diff --git a/test/unit/test_manage.py b/test/unit/test_manage.py index a560c87..914d107 100644 --- a/test/unit/test_manage.py +++ b/test/unit/test_manage.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import MagicMock, patch, ANY +from unittest.mock import MagicMock, patch, ANY, call import copy import yaml @@ -7,6 +7,8 @@ import typer from typer.testing import CliRunner +from openstack import exceptions as os_excs + from openstack_project_manager.manage import ( Configuration, get_quotaclass, @@ -17,6 +19,7 @@ check_volume_types, check_bandwidth_limit, manage_private_volumetypes, + manage_default_volume_type, check_flavors, manage_private_flavors, create_network_resources, @@ -620,6 +623,231 @@ def test_manage_private_volumetypes_1(self): self.config.os_cloud.block_storage.types.assert_not_called() +class TestManageDefaultVolumeType(TestBase): + + def _mock_type(self, id, name, is_public): + volume_type = MagicMock() + volume_type.id = id + volume_type.name = name + volume_type.is_public = is_public + return volume_type + + def _mock_default_type(self, project_id, volume_type_id): + default_type = MagicMock() + default_type.project_id = project_id + default_type.volume_type_id = volume_type_id + return default_type + + def _mock_block_storage_types(self, is_public=True): + for vt in self._mock_types: + if vt.is_public == is_public: + yield vt + + def setUp(self): + super().setUp() + + self.mock_project = MagicMock() + self.mock_project.id = 1234 + + self.mock_domain = MagicMock() + self.mock_domain.id = 5678 + self.mock_domain.name = "CoMpAnY" + + self._mock_types = [ + self._mock_type(x, "test_" + str(x), bool(x % 2)) + for x in range(1000, 2000, 123) + ] + + self.config.os_cloud.block_storage.types.side_effect = ( + self._mock_block_storage_types + ) + + def test_manage_default_volume_type_0(self): + """ + Test: + Default volume type not set for project. + No default volume type declared + """ + self.config.os_cloud.block_storage.show_default_type.side_effect = ( + os_excs.NotFoundException + ) + + manage_default_volume_type( + self.config, self.mock_project, self.mock_domain, "classes.yml" + ) + + self.config.os_cloud.block_storage.types.assert_not_called() + self.config.os_cloud.block_storage.show_default_type.assert_called_once_with( + self.mock_project + ) + self.config.os_cloud.block_storage.unset_default_type.assert_not_called() + self.config.os_cloud.block_storage.set_default_type.assert_not_called() + + def test_manage_default_volume_type_1(self): + """ + Test: + Default volume type not set for project. + Default existent volume type declared by id + """ + mock_default_volume_type = self._mock_types[0] + self.mock_project.default_volume_type = mock_default_volume_type.id + self.mock_project.__contains__.side_effect = lambda key: key in [ + "default_volume_type" + ] # NOTE: Mock 'default_volume_type' in mock_project + self.config.os_cloud.block_storage.show_default_type.side_effect = ( + os_excs.NotFoundException + ) + + manage_default_volume_type( + self.config, self.mock_project, self.mock_domain, "classes.yml" + ) + + self.config.os_cloud.block_storage.types.assert_has_calls( + [call(is_public=True), call(is_public=False)] + ) + self.config.os_cloud.block_storage.show_default_type.assert_called_once_with( + self.mock_project + ) + self.config.os_cloud.block_storage.unset_default_type.assert_not_called() + self.config.os_cloud.block_storage.set_default_type.assert_called_once_with( + self.mock_project, mock_default_volume_type + ) + + def test_manage_default_volume_type_2(self): + """ + Test: + Default volume type set for project. + Same existent default volume type declared by name + """ + mock_default_volume_type = self._mock_types[0] + self.mock_project.default_volume_type = mock_default_volume_type.name + self.mock_project.__contains__.side_effect = lambda key: key in [ + "default_volume_type" + ] # NOTE: Mock 'default_volume_type' in mock_project + self.config.os_cloud.block_storage.show_default_type.side_effect = ( + lambda project: self._mock_default_type(project.id, self._mock_types[0].id) + ) + + manage_default_volume_type( + self.config, self.mock_project, self.mock_domain, "classes.yml" + ) + + self.config.os_cloud.block_storage.types.assert_has_calls( + [call(is_public=True), call(is_public=False)] + ) + self.config.os_cloud.block_storage.show_default_type.assert_called_once_with( + self.mock_project + ) + self.config.os_cloud.block_storage.unset_default_type.assert_not_called() + self.config.os_cloud.block_storage.set_default_type.assert_not_called() + + def test_manage_default_volume_type_3(self): + """ + Test: + Default volume type set for project. + Different existent default volume type declared by name + """ + mock_default_volume_type = self._mock_types[1] + self.mock_project.default_volume_type = mock_default_volume_type.name + self.mock_project.__contains__.side_effect = lambda key: key in [ + "default_volume_type" + ] # NOTE: Mock 'default_volume_type' in mock_project + self.config.os_cloud.block_storage.show_default_type.side_effect = ( + lambda project: self._mock_default_type(project.id, self._mock_types[0].id) + ) + + manage_default_volume_type( + self.config, self.mock_project, self.mock_domain, "classes.yml" + ) + + self.config.os_cloud.block_storage.types.assert_has_calls( + [call(is_public=True), call(is_public=False)] + ) + self.config.os_cloud.block_storage.show_default_type.assert_called_once_with( + self.mock_project + ) + self.config.os_cloud.block_storage.unset_default_type.assert_not_called() + self.config.os_cloud.block_storage.set_default_type.assert_called_once_with( + self.mock_project, mock_default_volume_type + ) + + def test_manage_default_volume_type_4(self): + """ + Test: + Default volume type set for project. + Different non-existent default volume type declared by id + """ + mock_default_volume_type = self._mock_type(2000, "non_existent", False) + self.mock_project.default_volume_type = mock_default_volume_type.id + self.mock_project.__contains__.side_effect = lambda key: key in [ + "default_volume_type" + ] # NOTE: Mock 'default_volume_type' in mock_project + self.config.os_cloud.block_storage.show_default_type.side_effect = ( + lambda project: self._mock_default_type(project.id, self._mock_types[1].id) + ) + + manage_default_volume_type( + self.config, self.mock_project, self.mock_domain, "classes.yml" + ) + + self.config.os_cloud.block_storage.types.assert_has_calls( + [call(is_public=True), call(is_public=False)] + ) + self.config.os_cloud.block_storage.show_default_type.assert_not_called() + self.config.os_cloud.block_storage.unset_default_type.assert_not_called() + self.config.os_cloud.block_storage.set_default_type.assert_not_called() + + def test_manage_default_volume_type_5(self): + """ + Test: + Default volume type set for project. + No default volume type declared + """ + self.config.os_cloud.block_storage.show_default_type.side_effect = ( + lambda project: self._mock_default_type(project.id, self._mock_types[1].id) + ) + + manage_default_volume_type( + self.config, self.mock_project, self.mock_domain, "classes.yml" + ) + + self.config.os_cloud.block_storage.types.assert_not_called() + self.config.os_cloud.block_storage.show_default_type.assert_called_once_with( + self.mock_project + ) + self.config.os_cloud.block_storage.unset_default_type.assert_called_once_with( + self.mock_project + ) + self.config.os_cloud.block_storage.set_default_type.assert_not_called() + + def test_manage_default_volume_type_6(self): + """ + Test: + Default volume type set for project. + Default volume type declared, but unset + """ + self.mock_project.default_volume_type = "" + self.mock_project.__contains__.side_effect = lambda key: key in [ + "default_volume_type" + ] # NOTE: Mock 'default_volume_type' in mock_project + self.config.os_cloud.block_storage.show_default_type.side_effect = ( + lambda project: self._mock_default_type(project.id, self._mock_types[1].id) + ) + + manage_default_volume_type( + self.config, self.mock_project, self.mock_domain, "classes.yml" + ) + + self.config.os_cloud.block_storage.types.assert_not_called() + self.config.os_cloud.block_storage.show_default_type.assert_called_once_with( + self.mock_project + ) + self.config.os_cloud.block_storage.unset_default_type.assert_called_once_with( + self.mock_project + ) + self.config.os_cloud.block_storage.set_default_type.assert_not_called() + + class TestCheckPrivateFlavorTypes(TestBase): def setUp(self): @@ -1326,6 +1554,7 @@ def setUp(self): self.config.os_cloud.get_domain.return_value = self.mock_domain @patch("openstack_project_manager.manage.manage_private_flavors") + @patch("openstack_project_manager.manage.manage_default_volume_type") @patch("openstack_project_manager.manage.manage_private_volumetypes") @patch("openstack_project_manager.manage.check_volume_types") @patch("openstack_project_manager.manage.create_network_resources") @@ -1346,10 +1575,11 @@ def test_process_project_0( mock_create_network_resources, mock_check_volume_types, mock_manage_private_volumetypes, + mock_manage_default_volume_type, mock_manage_private_flavors, ): process_project( - self.config, self.mock_project, "classes.yaml", True, True, True, True + self.config, self.mock_project, "classes.yaml", True, True, True, True, True ) mock_check_quota.assert_called_once_with( @@ -1373,11 +1603,15 @@ def test_process_project_0( mock_manage_private_volumetypes.assert_called_once_with( self.config, self.mock_project, self.mock_domain ) + mock_manage_default_volume_type.assert_called_once_with( + self.config, self.mock_project, self.mock_domain, "classes.yaml" + ) mock_manage_private_flavors.assert_called_once_with( self.config, self.mock_project, self.mock_domain ) @patch("openstack_project_manager.manage.manage_private_flavors") + @patch("openstack_project_manager.manage.manage_default_volume_type") @patch("openstack_project_manager.manage.manage_private_volumetypes") @patch("openstack_project_manager.manage.check_volume_types") @patch("openstack_project_manager.manage.create_network_resources") @@ -1398,6 +1632,7 @@ def test_process_project_1( mock_create_network_resources, mock_check_volume_types, mock_manage_private_volumetypes, + mock_manage_default_volume_type, mock_manage_private_flavors, ): self.config.assign_admin_user = False @@ -1410,7 +1645,14 @@ def mock_contains(name): self.mock_project.get.return_value = "True" process_project( - self.config, self.mock_project, "classes.yaml", False, False, False, False + self.config, + self.mock_project, + "classes.yaml", + False, + False, + False, + False, + False, ) mock_check_quota.assert_called_once_with( @@ -1432,9 +1674,11 @@ def mock_contains(name): self.config, self.mock_project, self.mock_domain, "classes.yaml" ) mock_manage_private_volumetypes.assert_not_called() + mock_manage_default_volume_type.assert_not_called() mock_manage_private_flavors.assert_not_called() @patch("openstack_project_manager.manage.manage_private_flavors") + @patch("openstack_project_manager.manage.manage_default_volume_type") @patch("openstack_project_manager.manage.manage_private_volumetypes") @patch("openstack_project_manager.manage.check_volume_types") @patch("openstack_project_manager.manage.create_network_resources") @@ -1455,6 +1699,7 @@ def test_process_project_2( mock_create_network_resources, mock_check_volume_types, mock_manage_private_volumetypes, + mock_manage_default_volume_type, mock_manage_private_flavors, ): def mock_contains(name): @@ -1465,7 +1710,7 @@ def mock_contains(name): self.mock_project.get.return_value = "True" process_project( - self.config, self.mock_project, "classes.yaml", True, True, True, True + self.config, self.mock_project, "classes.yaml", True, True, True, True, True ) mock_check_quota.assert_not_called() @@ -1477,6 +1722,7 @@ def mock_contains(name): mock_create_network_resources.assert_not_called() mock_check_volume_types.assert_not_called() mock_manage_private_volumetypes.assert_not_called() + mock_manage_default_volume_type.assert_not_called() mock_manage_private_flavors.assert_not_called() @patch("openstack_project_manager.manage.check_quota") @@ -1578,7 +1824,7 @@ def mock_list_projects(domain_id=None): def assume_project_1(self, assume_cache_images): self.mock_handle_unmanaged_project.assert_not_called() self.mock_process_project.assert_called_once_with( - ANY, self.mock_project1, ANY, False, False, True, True + ANY, self.mock_project1, ANY, False, False, True, True, True ) if not assume_cache_images: self.mock_cache_images.assert_not_called() @@ -1613,7 +1859,7 @@ def test_cli_1(self): ANY, self.mock_project2, ANY ) self.mock_process_project.assert_called_once_with( - ANY, self.mock_project1, ANY, False, False, True, True + ANY, self.mock_project1, ANY, False, False, True, True, True ) self.mock_cache_images.assert_any_call(ANY, self.mock_domain1) self.mock_cache_images.assert_any_call(ANY, self.mock_domain2) @@ -1678,6 +1924,7 @@ def test_cli_8(self): "--manage-endpoints", "--manage-homeprojects", "--nomanage-privatevolumetypes", + "--nomanage-defaultvolumetype", "--nomanage-privateflavors", "--classes=other.yaml", ], @@ -1685,7 +1932,7 @@ def test_cli_8(self): self.assertEqual(result.exit_code, 0, (result, result.stdout)) self.mock_process_project.assert_called_once_with( - ANY, self.mock_project1, "other.yaml", True, True, False, False + ANY, self.mock_project1, "other.yaml", True, True, False, False, False ) def test_cli_9(self):