diff --git a/releasenotes/notes/prepare-openstacksdk-migration-73cc43ab26ed47e6.yaml b/releasenotes/notes/prepare-openstacksdk-migration-73cc43ab26ed47e6.yaml new file mode 100644 index 000000000..e9fb16508 --- /dev/null +++ b/releasenotes/notes/prepare-openstacksdk-migration-73cc43ab26ed47e6.yaml @@ -0,0 +1,13 @@ +--- +upgrade: + - | + Connection settings for Nova should be added + directly to the [nova] section of the configuration now, instead of nova_client. +deprecations: + - | + The nova_client configuration options are deprecated and will be removed + in a future release. Operators should migrate to the keystoneauth adapter + configuration options in the [nova] configuration group. +other: + - | + Added support for openstacksdk as an alternative to novaclient. diff --git a/requirements.txt b/requirements.txt index 489238a18..d23d168bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,6 +32,7 @@ pbr>=3.1.1 # Apache-2.0 pecan>=1.3.2 # BSD PrettyTable>=0.7.2 # BSD gnocchiclient>=7.0.1 # Apache-2.0 +openstacksdk>=4.4.0 # Apache-2.0 python-cinderclient>=3.5.0 # Apache-2.0 python-keystoneclient>=3.15.0 # Apache-2.0 python-novaclient>=14.1.0 # Apache-2.0 diff --git a/watcher/cmd/status.py b/watcher/cmd/status.py index bab78cc1d..c34e4dc1f 100644 --- a/watcher/cmd/status.py +++ b/watcher/cmd/status.py @@ -33,9 +33,9 @@ class Checks(upgradecheck.UpgradeCommands): """ def _minimum_nova_api_version(self): - """Checks the minimum required version of nova_client.api_version""" + """Checks the minimum required version of nova.api_version""" try: - clients.check_min_nova_api_version(CONF.nova_client.api_version) + clients.check_min_nova_api_version(CONF.nova.api_version) except ValueError as e: return upgradecheck.Result( upgradecheck.Code.FAILURE, str(e)) diff --git a/watcher/common/clients.py b/watcher/common/clients.py index 3eedc806e..acd16bf46 100644 --- a/watcher/common/clients.py +++ b/watcher/common/clients.py @@ -22,6 +22,7 @@ from keystoneauth1 import loading as ka_loading from keystoneclient import client as keyclient from novaclient import api_versions as nova_api_versions from novaclient import client as nvclient +from openstack import connection from watcher.common import exception from watcher.common import utils @@ -45,6 +46,42 @@ MIN_NOVA_API_VERSION = '2.56' warnings.simplefilter("once") +def get_sdk_connection(session=None, context=None): + """Create and return an OpenStackSDK Connection object. + + :param session: Optional keystone session. If not provided, a new session + will be created using the configured auth parameters. + :param context: Optional context object, use to get user's token. + :returns: An OpenStackSDK Connection object + """ + # NOTE(jgilaber): load the auth plugin from the config in case it's never + # been loaded before. The auth plugin is only used when creating a new + # session, but we need to ensure the auth_url config value is set to use + # the user token from the context object + auth = ka_loading.load_auth_from_conf_options( + CONF, _CLIENTS_AUTH_GROUP + ) + if context is not None: + # create a connection using the user's token if unavailable + conn = connection.Connection( + token=context.auth_token, + auth_type="v3token", + project_id=context.project_id, + project_domain_id=context.project_domain_id, + auth_url=CONF.watcher_clients_auth.auth_url + ) + return conn + + if session is None: + # if we don't have a user token nor a created session, create a new + # one + session = ka_loading.load_session_from_conf_options( + CONF, _CLIENTS_AUTH_GROUP, auth=auth + ) + + return connection.Connection(session=session, oslo_conf=CONF) + + def check_min_nova_api_version(config_version): """Validates the minimum required nova API version. @@ -54,7 +91,7 @@ def check_min_nova_api_version(config_version): """ min_required = nova_api_versions.APIVersion(MIN_NOVA_API_VERSION) if nova_api_versions.APIVersion(config_version) < min_required: - raise ValueError(f'Invalid nova_client.api_version {config_version}. ' + raise ValueError(f'Invalid nova.api_version {config_version}. ' f'{MIN_NOVA_API_VERSION} or greater is required.') @@ -116,7 +153,7 @@ class OpenStackClients: if self._nova: return self._nova - novaclient_version = self._get_client_option('nova', 'api_version') + novaclient_version = CONF.nova.api_version check_min_nova_api_version(novaclient_version) diff --git a/watcher/common/nova_helper.py b/watcher/common/nova_helper.py index 6f6257f20..04a87c1f4 100644 --- a/watcher/common/nova_helper.py +++ b/watcher/common/nova_helper.py @@ -114,6 +114,36 @@ class Server: ) ) + @classmethod + def from_openstacksdk(cls, nova_server): + """Create a Server dataclass from a OpenStackSDK Server object. + + :param nova_server: OpenStackSDK Server object + :returns: Server dataclass instance + :raises: InvalidUUID if server ID is not a valid UUID + """ + try: + uuid.UUID(nova_server.id) + except (ValueError, AttributeError, TypeError): + raise exception.InvalidUUID(uuid=nova_server.id) + + return cls( + uuid=nova_server.id, + name=nova_server.name, + created=nova_server.created_at, + host=nova_server.compute_host, + vm_state=nova_server.vm_state, + task_state=nova_server.task_state, + power_state=nova_server.power_state, + status=nova_server.status, + flavor=nova_server.flavor, + tenant_id=nova_server.project_id, + locked=nova_server.is_locked, + metadata=nova_server.metadata, + availability_zone=nova_server.availability_zone, + pinned_availability_zone=nova_server.pinned_availability_zone + ) + @dc.dataclass(frozen=True) class Hypervisor: @@ -182,6 +212,50 @@ class Hypervisor: servers=servers, ) + @classmethod + def from_openstacksdk(cls, nova_hypervisor): + """Create a Hypervisor dataclass from a OpenStackSDK Hypervisor object. + + :param nova_hypervisor: OpenStackSDK Hypervisor object + :returns: Hypervisor dataclass instance + :raises: InvalidUUID if hypervisor ID is not a valid UUID + """ + service = nova_hypervisor.service_details + service_host = None + service_id = None + service_disabled_reason = None + if isinstance(service, dict): + service_host = service.get('host') + service_id = service.get('id') + service_disabled_reason = service.get('disabled_reason') + + servers = nova_hypervisor.servers + if servers is None: + servers = [] + + try: + uuid.UUID(nova_hypervisor.id) + except (ValueError, AttributeError, TypeError): + raise exception.InvalidUUID(uuid=nova_hypervisor.id) + + return cls( + uuid=nova_hypervisor.id, + hypervisor_hostname=nova_hypervisor.name, + hypervisor_type=nova_hypervisor.hypervisor_type, + state=nova_hypervisor.state, + status=nova_hypervisor.status, + vcpus=nova_hypervisor.vcpus, + vcpus_used=nova_hypervisor.vcpus_used, + memory_mb=nova_hypervisor.memory_size, + memory_mb_used=nova_hypervisor.memory_used, + local_gb=nova_hypervisor.local_disk_size, + local_gb_used=nova_hypervisor.local_disk_used, + service_host=service_host, + service_id=service_id, + service_disabled_reason=service_disabled_reason, + servers=servers, + ) + @dc.dataclass(frozen=True) class Flavor: @@ -226,6 +300,26 @@ class Flavor: extra_specs=flavor_dict.get('extra_specs', {}) ) + @classmethod + def from_openstacksdk(cls, nova_flavor): + """Create a Flavor dataclass from a OpenStackSDK Flavor object. + + :param nova_flavor: OpenStackSDK Flavor object + :returns: Flavor dataclass instance + """ + + return cls( + id=nova_flavor.id, + flavor_name=nova_flavor.name, + vcpus=nova_flavor.vcpus, + ram=nova_flavor.ram, + disk=nova_flavor.disk, + ephemeral=nova_flavor.ephemeral, + swap=nova_flavor.swap, + is_public=nova_flavor.is_public, + extra_specs=nova_flavor.extra_specs + ) + @dc.dataclass(frozen=True) class Aggregate: @@ -256,6 +350,21 @@ class Aggregate: metadata=nova_aggregate.metadata, ) + @classmethod + def from_openstacksdk(cls, nova_aggregate): + """Create an Aggregate dataclass from a OpenStackSDK Aggregate object. + + :param nova_aggregate: OpenStackSDK Aggregate object + :returns: Aggregate dataclass instance + """ + return cls( + id=nova_aggregate.id, + name=nova_aggregate.name, + availability_zone=nova_aggregate.availability_zone, + hosts=nova_aggregate.hosts, + metadata=nova_aggregate.metadata, + ) + @dc.dataclass(frozen=True) class Service: @@ -298,6 +407,30 @@ class Service: disabled_reason=nova_service.disabled_reason, ) + @classmethod + def from_openstacksdk(cls, nova_service): + """Create a Service dataclass from a OpenStackSDK Service object. + + :param nova_service: OpenStackSDK Service object + :returns: Service dataclass instance + :raises: InvalidUUID if service ID is not a valid UUID + """ + try: + uuid.UUID(nova_service.id) + except (ValueError, AttributeError, TypeError): + raise exception.InvalidUUID(uuid=nova_service.id) + + return cls( + uuid=nova_service.id, + binary=nova_service.binary, + host=nova_service.host, + zone=nova_service.availability_zone, + status=nova_service.status, + state=nova_service.state, + updated_at=nova_service.updated_at, + disabled_reason=nova_service.disabled_reason, + ) + @dc.dataclass(frozen=True) class ServerMigration: @@ -320,6 +453,17 @@ class ServerMigration: id=nova_migration.id, ) + @classmethod + def from_openstacksdk(cls, nova_migration): + """Create a ServerMigration from a OpenStackSDK ServerMigration. + + :param nova_migration: OpenStackSDK ServerMigration + :returns: ServerMigration dataclass instance + """ + return cls( + id=nova_migration.id, + ) + class NovaHelper: @@ -338,7 +482,7 @@ class NovaHelper: if self._is_pinned_az_available is None: self._is_pinned_az_available = ( api_versions.APIVersion( - version_str=CONF.nova_client.api_version) >= + version_str=CONF.nova.api_version) >= api_versions.APIVersion(version_str='2.96')) return self._is_pinned_az_available @@ -773,7 +917,7 @@ class NovaHelper: @nova_retries def enable_service_nova_compute(self, hostname): - if (api_versions.APIVersion(version_str=CONF.nova_client.api_version) < + if (api_versions.APIVersion(version_str=CONF.nova.api_version) < api_versions.APIVersion(version_str='2.53')): status = self.nova.services.enable( host=hostname, binary='nova-compute').status == 'enabled' @@ -787,7 +931,7 @@ class NovaHelper: @nova_retries def disable_service_nova_compute(self, hostname, reason=None): - if (api_versions.APIVersion(version_str=CONF.nova_client.api_version) < + if (api_versions.APIVersion(version_str=CONF.nova.api_version) < api_versions.APIVersion(version_str='2.53')): status = self.nova.services.disable_log_reason( host=hostname, diff --git a/watcher/conf/nova.py b/watcher/conf/nova.py index 160e124a7..4eaeae3e6 100644 --- a/watcher/conf/nova.py +++ b/watcher/conf/nova.py @@ -13,8 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from keystoneauth1 import loading as ks_loading from oslo_config import cfg +from watcher.common import clients + nova = cfg.OptGroup(name='nova', title='Options for the Nova integration ' 'configuration') @@ -49,12 +52,52 @@ NOVA_OPTS = [ help='Interval in seconds to retry HTTP requests to the Nova ' 'service when connection errors occur. Default is 2 ' 'seconds.'), + # Options migrated from nova_client group (deprecated in 2026.1) + cfg.StrOpt('api_version', + default='2.56', + deprecated_group='nova_client', + help=f""" +Version of Nova API to use in novaclient. + +Minimum required version: {clients.MIN_NOVA_API_VERSION} + +Certain Watcher features depend on a minimum version of the compute +API being available which is enforced with this option. See +https://docs.openstack.org/nova/latest/reference/api-microversion-history.html +for the compute API microversion history. +"""), ] +def handle_nova_client_deprecations(): + """Override configuration values from nova. + + If the user has set values for deprecated options in nova_client, set the + values to the new options in the nova group. + """ + loc = cfg.CONF.get_location('endpoint_type', 'nova_client') + if loc and loc.location != cfg.Locations.opt_default: + endpoint_type = cfg.CONF.nova_client.endpoint_type.replace('URL', '') + cfg.CONF.set_default('valid_interfaces', [endpoint_type], 'nova') + + loc = cfg.CONF.get_location('api_version', 'nova_client') + if loc and loc.location != cfg.Locations.opt_default: + api_version = cfg.CONF.nova_client.api_version + cfg.CONF.set_default('api_version', api_version, 'nova') + + loc = cfg.CONF.get_location('region_name', 'nova_client') + if loc and loc.location != cfg.Locations.opt_default: + region_name = cfg.CONF.nova_client.api_version + cfg.CONF.set_default('region_name', region_name, 'nova') + + def register_opts(conf): conf.register_group(nova) conf.register_opts(NOVA_OPTS, group=nova) + ks_loading.register_adapter_conf_options( + cfg.CONF, nova, include_deprecated=False + ) + handle_nova_client_deprecations() def list_opts(): diff --git a/watcher/conf/nova_client.py b/watcher/conf/nova_client.py index 2a5d7a8d8..c7e5d4f0b 100644 --- a/watcher/conf/nova_client.py +++ b/watcher/conf/nova_client.py @@ -17,6 +17,7 @@ from oslo_config import cfg +from watcher._i18n import _ from watcher.common import clients nova_client = cfg.OptGroup(name='nova_client', @@ -25,6 +26,12 @@ nova_client = cfg.OptGroup(name='nova_client', NOVA_CLIENT_OPTS = [ cfg.StrOpt('api_version', default='2.56', + deprecated_for_removal=True, + deprecated_reason=_( + 'To replace the frozen novaclient with the ' + 'openstacksdk compute proxy, the options need to ' + 'be under the [nova] group.' + ), help=f""" Version of Nova API to use in novaclient. @@ -37,10 +44,22 @@ for the compute API microversion history. """), cfg.StrOpt('endpoint_type', default='publicURL', + deprecated_for_removal=True, + deprecated_reason=_( + 'This option was replaced by the valid_interfaces' + 'option defined by keystoneauth.' + ), + deprecated_since='2026.1', choices=['public', 'internal', 'admin', 'publicURL', 'internalURL', 'adminURL'], help='Type of endpoint to use in novaclient.'), cfg.StrOpt('region_name', + deprecated_for_removal=True, + deprecated_reason=_( + 'This option was replaced by the region_name' + 'option defined by keystoneauth.' + ), + deprecated_since='2026.1', help='Region in Identity service catalog to use for ' 'communication with the OpenStack service.')] diff --git a/watcher/tests/unit/cmd/test_status.py b/watcher/tests/unit/cmd/test_status.py index 2c9786ccc..e0e2b8f33 100644 --- a/watcher/tests/unit/cmd/test_status.py +++ b/watcher/tests/unit/cmd/test_status.py @@ -36,7 +36,7 @@ class TestUpgradeChecks(base.TestCase): def test_minimum_nova_api_version_fail(self): # Tests the scenario that [nova_client]/api_version is less than the # minimum required version. - CONF.set_override('api_version', '2.47', group='nova_client') + CONF.set_override('api_version', '2.47', group='nova') result = self.cmd._minimum_nova_api_version() self.assertEqual(Code.FAILURE, result.code) - self.assertIn('Invalid nova_client.api_version 2.47.', result.details) + self.assertIn('Invalid nova.api_version 2.47.', result.details) diff --git a/watcher/tests/unit/common/test_clients.py b/watcher/tests/unit/common/test_clients.py index e12a4ba7a..5e950ead0 100644 --- a/watcher/tests/unit/common/test_clients.py +++ b/watcher/tests/unit/common/test_clients.py @@ -30,13 +30,14 @@ except Exception: # ImportError or others from novaclient import client as nvclient from watcher.common import clients +from watcher.common import context from watcher import conf from watcher.tests.unit import base CONF = conf.CONF -class TestClients(base.TestCase): +class TestBaseClients(base.TestCase): def _register_watcher_clients_auth_opts(self): _AUTH_CONF_GROUP = 'watcher_clients_auth' @@ -90,6 +91,9 @@ class TestClients(base.TestCase): CONF.register_opts = mock_register_opts + +class TestClients(TestBaseClients): + def test_get_keystone_session(self): self._register_watcher_clients_auth_opts() @@ -116,14 +120,14 @@ class TestClients(base.TestCase): osc._nova = None osc.nova() mock_call.assert_called_once_with( - CONF.nova_client.api_version, + CONF.nova.api_version, endpoint_type=CONF.nova_client.endpoint_type, region_name=CONF.nova_client.region_name, session=mock_session) @mock.patch.object(clients.OpenStackClients, 'session') def test_clients_nova_diff_vers(self, mock_session): - CONF.set_override('api_version', '2.60', group='nova_client') + CONF.set_override('api_version', '2.60', group='nova') osc = clients.OpenStackClients() osc._nova = None osc.nova() @@ -131,11 +135,11 @@ class TestClients(base.TestCase): @mock.patch.object(clients.OpenStackClients, 'session') def test_clients_nova_bad_min_version(self, mock_session): - CONF.set_override('api_version', '2.47', group='nova_client') + CONF.set_override('api_version', '2.47', group='nova') osc = clients.OpenStackClients() osc._nova = None ex = self.assertRaises(ValueError, osc.nova) - self.assertIn('Invalid nova_client.api_version 2.47', str(ex)) + self.assertIn('Invalid nova.api_version 2.47', str(ex)) @mock.patch.object(clients.OpenStackClients, 'session') def test_clients_nova_diff_endpoint(self, mock_session): @@ -344,3 +348,82 @@ class TestClients(base.TestCase): interface=CONF.placement_client.interface, region_name=CONF.placement_client.region_name, additional_headers=headers) + + +class TestGetSDKConnection(TestBaseClients): + """Test cases for get_sdk_connection function.""" + + @mock.patch('openstack.connection.Connection', autospec=True) + def test_get_sdk_connection_with_context( + self, mock_connect): + """Test SDK connection creation with context.""" + self._register_watcher_clients_auth_opts() + + context_obj = context.RequestContext( + auth_token='test_token', project_id='test_project_id', + project_domain='test_project_domain_id' + ) + mock_connection = mock.Mock() + mock_connect.return_value = mock_connection + + result = clients.get_sdk_connection(context=context_obj) + + mock_connect.assert_called_once_with( + token='test_token', + auth_type='v3token', + project_id='test_project_id', + project_domain_id='test_project_domain_id', + auth_url='http://server.ip:5000' + ) + self.assertEqual(mock_connection, result) + + @mock.patch.object(ka_loading, 'load_auth_from_conf_options', + autospec=True) + @mock.patch('openstack.connection.Connection', autospec=True) + def test_get_sdk_connection_with_session( + self, mock_connect, mock_load_auth): + """Test SDK connection creation with provided session.""" + mock_session = mock.Mock() + mock_connection = mock.Mock() + mock_connect.return_value = mock_connection + + result = clients.get_sdk_connection(session=mock_session) + + mock_connect.assert_called_once_with( + session=mock_session, + oslo_conf=CONF + ) + mock_load_auth.assert_called_once_with( + CONF, 'watcher_clients_auth' + ) + self.assertEqual(mock_connection, result) + + @mock.patch.object(ka_loading, 'load_session_from_conf_options', + autospec=True) + @mock.patch.object(ka_loading, 'load_auth_from_conf_options', + autospec=True) + @mock.patch('openstack.connection.Connection', autospec=True) + def test_get_sdk_connection_no_session_no_context( + self, mock_connect, mock_load_auth, mock_load_session): + """Test SDK connection creation without session or context.""" + mock_auth = mock.Mock() + mock_session = mock.Mock() + mock_connection = mock.Mock() + mock_load_auth.return_value = mock_auth + mock_load_session.return_value = mock_session + mock_connect.return_value = mock_connection + + result = clients.get_sdk_connection() + + mock_load_auth.assert_called_once_with( + CONF, 'watcher_clients_auth' + ) + mock_load_session.assert_called_once_with( + CONF, + 'watcher_clients_auth', + auth=mock_auth) + mock_connect.assert_called_once_with( + session=mock_session, + oslo_conf=CONF + ) + self.assertEqual(mock_connection, result) diff --git a/watcher/tests/unit/common/test_nova_helper.py b/watcher/tests/unit/common/test_nova_helper.py index e496d5a83..810719711 100644 --- a/watcher/tests/unit/common/test_nova_helper.py +++ b/watcher/tests/unit/common/test_nova_helper.py @@ -932,7 +932,7 @@ class TestNovaHelper(test_utils.NovaResourcesMixin, base.TestCase): nova_services.enable.return_value = mock.MagicMock( status='enabled') - CONF.set_override('api_version', '2.52', group='nova_client') + CONF.set_override('api_version', '2.52', group='nova') result = nova_util.enable_service_nova_compute('nanjing') self.assertTrue(result) @@ -943,7 +943,7 @@ class TestNovaHelper(test_utils.NovaResourcesMixin, base.TestCase): nova_services.enable.return_value = mock.MagicMock( status='disabled') - CONF.set_override('api_version', '2.56', group='nova_client') + CONF.set_override('api_version', '2.56', group='nova') result = nova_util.enable_service_nova_compute('nanjing') self.assertFalse(result) @@ -957,7 +957,7 @@ class TestNovaHelper(test_utils.NovaResourcesMixin, base.TestCase): nova_services.disable_log_reason.return_value = mock.MagicMock( status='enabled') - CONF.set_override('api_version', '2.52', group='nova_client') + CONF.set_override('api_version', '2.52', group='nova') result = nova_util.disable_service_nova_compute( 'nanjing', reason='test') @@ -969,7 +969,7 @@ class TestNovaHelper(test_utils.NovaResourcesMixin, base.TestCase): nova_services.disable_log_reason.return_value = mock.MagicMock( status='disabled') - CONF.set_override('api_version', '2.56', group='nova_client') + CONF.set_override('api_version', '2.56', group='nova') result = nova_util.disable_service_nova_compute( 'nanjing', reason='test2') @@ -1453,6 +1453,64 @@ class TestServerWrapper(test_utils.NovaResourcesMixin, base.TestCase): self.assertNotEqual(server1a, "not-a-server") self.assertIsNotNone(server1a) + def test_server_from_openstacksdk_basic_properties(self): + """Test Server.from_openstacksdk with basic properties.""" + server_id = utils.generate_uuid() + sdk_server = self.create_openstacksdk_server( + id=server_id, + name='my-server', + status='ACTIVE', + created_at='2026-01-01T00:00:00Z', + project_id='tenant-123', + is_locked=True, + metadata={'key': 'value'}, + pinned_availability_zone='az1' + ) + + wrapped = nova_helper.Server.from_openstacksdk(sdk_server) + + self.assertEqual(server_id, wrapped.uuid) + self.assertEqual('my-server', wrapped.name) + self.assertEqual('ACTIVE', wrapped.status) + self.assertEqual('2026-01-01T00:00:00Z', wrapped.created) + self.assertEqual('tenant-123', wrapped.tenant_id) + self.assertTrue(wrapped.locked) + self.assertEqual({'key': 'value'}, wrapped.metadata) + self.assertEqual('az1', wrapped.pinned_availability_zone) + + def test_server_from_openstacksdk_extended_attributes(self): + """Test Server.from_openstacksdk with extended attributes.""" + server_id = utils.generate_uuid() + sdk_server = self.create_openstacksdk_server( + id=server_id, + compute_host='compute-1', + vm_state='active', + task_state=None, + power_state=1, + availability_zone='nova' + ) + + wrapped = nova_helper.Server.from_openstacksdk(sdk_server) + + self.assertEqual('compute-1', wrapped.host) + self.assertEqual('active', wrapped.vm_state) + self.assertIsNone(wrapped.task_state) + self.assertEqual(1, wrapped.power_state) + self.assertEqual('nova', wrapped.availability_zone) + + def test_server_from_openstacksdk_flavor(self): + """Test Server.from_openstacksdk flavor property.""" + server_id = utils.generate_uuid() + sdk_server = self.create_openstacksdk_server( + id=server_id, + flavor={'id': 'flavor-123', 'name': 'm1.small'} + ) + + wrapped = nova_helper.Server.from_openstacksdk(sdk_server) + # OpenStackSDK converts flavor dict to Flavor object + self.assertEqual('flavor-123', wrapped.flavor.id) + self.assertEqual('m1.small', wrapped.flavor.name) + class TestHypervisorWrapper(test_utils.NovaResourcesMixin, base.TestCase): """Test suite for the Hypervisor dataclass.""" @@ -1594,6 +1652,111 @@ class TestHypervisorWrapper(test_utils.NovaResourcesMixin, base.TestCase): # Compare with non-Hypervisor object self.assertNotEqual(hyp1a, "not-a-hypervisor") + def test_hypervisor_from_openstacksdk_basic_properties(self): + """Test Hypervisor.from_openstacksdk with basic properties.""" + hypervisor_id = utils.generate_uuid() + hostname = 'compute-node-1' + sdk_hypervisor = self.create_openstacksdk_hypervisor( + id=hypervisor_id, + name=hostname, + hypervisor_type='QEMU', + state='up', + status='enabled', + vcpus=32, + vcpus_used=8, + memory_size=65536, + memory_used=16384, + local_disk_size=1000, + local_disk_used=250 + ) + + wrapped = nova_helper.Hypervisor.from_openstacksdk(sdk_hypervisor) + + self.assertEqual(hypervisor_id, wrapped.uuid) + self.assertEqual(hostname, wrapped.hypervisor_hostname) + self.assertEqual('QEMU', wrapped.hypervisor_type) + self.assertEqual('up', wrapped.state) + self.assertEqual('enabled', wrapped.status) + self.assertEqual(32, wrapped.vcpus) + self.assertEqual(8, wrapped.vcpus_used) + self.assertEqual(65536, wrapped.memory_mb) + self.assertEqual(16384, wrapped.memory_mb_used) + self.assertEqual(1000, wrapped.local_gb) + self.assertEqual(250, wrapped.local_gb_used) + + def test_hypervisor_from_openstacksdk_service_properties(self): + """Test Hypervisor.from_openstacksdk service properties.""" + hostname = 'compute-node-1' + sdk_hypervisor = self.create_openstacksdk_hypervisor( + id=utils.generate_uuid(), + name=hostname, + service_details={ + 'host': hostname, + 'id': 42, + 'disabled_reason': 'maintenance' + } + ) + + wrapped = nova_helper.Hypervisor.from_openstacksdk(sdk_hypervisor) + + self.assertEqual(hostname, wrapped.service_host) + self.assertEqual(42, wrapped.service_id) + self.assertEqual('maintenance', wrapped.service_disabled_reason) + + def test_hypervisor_from_openstacksdk_service_not_dict(self): + """Test Hypervisor.from_openstacksdk when service is not a dict.""" + sdk_hypervisor = self.create_openstacksdk_hypervisor( + id=utils.generate_uuid(), + name='compute-node-1', + service_details='not-a-dict' + ) + + wrapped = nova_helper.Hypervisor.from_openstacksdk(sdk_hypervisor) + + self.assertIsNone(wrapped.service_host) + self.assertIsNone(wrapped.service_id) + self.assertIsNone(wrapped.service_disabled_reason) + + def test_hypervisor_from_openstacksdk_servers_property(self): + """Test Hypervisor.from_openstacksdk servers property.""" + hypervisor_id = utils.generate_uuid() + hostname = 'compute-node-1' + + server1_id = utils.generate_uuid() + server2_id = utils.generate_uuid() + server1 = { + 'uuid': server1_id, + 'name': 'server1', + } + server2 = { + 'uuid': server2_id, + 'name': 'server2' + } + + sdk_hypervisor = self.create_openstacksdk_hypervisor( + id=hypervisor_id, + name=hostname, + servers=[server1, server2] + ) + + wrapped = nova_helper.Hypervisor.from_openstacksdk(sdk_hypervisor) + + result_servers = wrapped.servers + self.assertEqual(2, len(result_servers)) + self.assertEqual(server1_id, result_servers[0]['uuid']) + self.assertEqual(server2_id, result_servers[1]['uuid']) + + def test_hypervisor_from_openstacksdk_servers_none(self): + """Test Hypervisor.from_openstacksdk when servers is None.""" + sdk_hypervisor = self.create_openstacksdk_hypervisor( + id=utils.generate_uuid(), + name='compute-node-1', + servers=None + ) + + wrapped = nova_helper.Hypervisor.from_openstacksdk(sdk_hypervisor) + self.assertEqual([], wrapped.servers) + class TestFlavorWrapper(test_utils.NovaResourcesMixin, base.TestCase): """Test suite for the Flavor dataclass.""" @@ -1695,6 +1858,82 @@ class TestFlavorWrapper(test_utils.NovaResourcesMixin, base.TestCase): # Compare with non-Flavor object self.assertNotEqual(flavor1a, "not-a-flavor") + def test_flavor_from_openstacksdk_basic_properties(self): + """Test Flavor.from_openstacksdk with basic properties.""" + flavor_id = utils.generate_uuid() + sdk_flavor = self.create_openstacksdk_flavor( + id=flavor_id, + name='m1.small', + vcpus=2, + ram=2048, + disk=20, + ephemeral=10, + swap=512, + is_public=True + ) + + wrapped = nova_helper.Flavor.from_openstacksdk(sdk_flavor) + + self.assertEqual(flavor_id, wrapped.id) + self.assertEqual('m1.small', wrapped.flavor_name) + self.assertEqual(2, wrapped.vcpus) + self.assertEqual(2048, wrapped.ram) + self.assertEqual(20, wrapped.disk) + self.assertEqual(10, wrapped.ephemeral) + self.assertEqual(512, wrapped.swap) + self.assertTrue(wrapped.is_public) + + def test_flavor_from_openstacksdk_zero_swap(self): + """Test Flavor.from_openstacksdk with zero swap.""" + flavor_id = utils.generate_uuid() + sdk_flavor = self.create_openstacksdk_flavor( + id=flavor_id, + name='m1.noswap', + swap=0 + ) + + wrapped = nova_helper.Flavor.from_openstacksdk(sdk_flavor) + self.assertEqual(0, wrapped.swap) + + def test_flavor_from_openstacksdk_private(self): + """Test Flavor.from_openstacksdk with private flavor.""" + flavor_id = utils.generate_uuid() + sdk_flavor = self.create_openstacksdk_flavor( + id=flavor_id, + name='m1.private', + is_public=False + ) + + wrapped = nova_helper.Flavor.from_openstacksdk(sdk_flavor) + self.assertFalse(wrapped.is_public) + + def test_flavor_from_openstacksdk_with_extra_specs(self): + """Test Flavor.from_openstacksdk with extra_specs.""" + flavor_id = utils.generate_uuid() + sdk_flavor = self.create_openstacksdk_flavor( + id=flavor_id, + name='m1.compute', + extra_specs={'hw:cpu_policy': 'dedicated', 'hw:numa_nodes': '2'} + ) + + wrapped = nova_helper.Flavor.from_openstacksdk(sdk_flavor) + + self.assertEqual( + {'hw:cpu_policy': 'dedicated', 'hw:numa_nodes': '2'}, + wrapped.extra_specs + ) + + def test_flavor_from_openstacksdk_without_extra_specs(self): + """Test Flavor.from_openstacksdk without extra_specs.""" + flavor_id = utils.generate_uuid() + sdk_flavor = self.create_openstacksdk_flavor( + id=flavor_id, + name='m1.basic' + ) + + wrapped = nova_helper.Flavor.from_openstacksdk(sdk_flavor) + self.assertEqual({}, wrapped.extra_specs) + class TestAggregateWrapper(test_utils.NovaResourcesMixin, base.TestCase): """Test suite for the Aggregate dataclass.""" @@ -1751,6 +1990,37 @@ class TestAggregateWrapper(test_utils.NovaResourcesMixin, base.TestCase): # Compare with non-Aggregate object self.assertNotEqual(agg1a, "not-an-aggregate") + def test_aggregate_from_openstacksdk_basic_properties(self): + """Test Aggregate.from_openstacksdk with basic properties.""" + aggregate_id = utils.generate_uuid() + sdk_aggregate = self.create_openstacksdk_aggregate( + id=aggregate_id, + name='test-aggregate', + availability_zone='az1', + hosts=['host1', 'host2', 'host3'], + metadata={'ssd': 'true', 'gpu': 'nvidia'} + ) + + wrapped = nova_helper.Aggregate.from_openstacksdk(sdk_aggregate) + + self.assertEqual(aggregate_id, wrapped.id) + self.assertEqual('test-aggregate', wrapped.name) + self.assertEqual('az1', wrapped.availability_zone) + self.assertEqual(['host1', 'host2', 'host3'], wrapped.hosts) + self.assertEqual({'ssd': 'true', 'gpu': 'nvidia'}, wrapped.metadata) + + def test_aggregate_from_openstacksdk_no_az(self): + """Test Aggregate.from_openstacksdk without availability zone.""" + aggregate_id = utils.generate_uuid() + sdk_aggregate = self.create_openstacksdk_aggregate( + id=aggregate_id, + name='test-aggregate', + availability_zone=None + ) + + wrapped = nova_helper.Aggregate.from_openstacksdk(sdk_aggregate) + self.assertIsNone(wrapped.availability_zone) + class TestServiceWrapper(test_utils.NovaResourcesMixin, base.TestCase): """Test suite for the Service dataclass.""" @@ -1817,6 +2087,47 @@ class TestServiceWrapper(test_utils.NovaResourcesMixin, base.TestCase): # Compare with non-Service object self.assertNotEqual(svc1a, "not-a-service") + def test_service_from_openstacksdk_basic_properties(self): + """Test Service.from_openstacksdk with basic properties.""" + service_id = utils.generate_uuid() + sdk_service = self.create_openstacksdk_service( + id=service_id, + binary='nova-compute', + host='compute-node-1', + availability_zone='az1', + status='enabled', + state='up', + updated_at='2026-01-09T12:00:00Z', + disabled_reason=None + ) + + wrapped = nova_helper.Service.from_openstacksdk(sdk_service) + + self.assertEqual(service_id, wrapped.uuid) + self.assertEqual('nova-compute', wrapped.binary) + self.assertEqual('compute-node-1', wrapped.host) + self.assertEqual('az1', wrapped.zone) + self.assertEqual('enabled', wrapped.status) + self.assertEqual('up', wrapped.state) + self.assertEqual('2026-01-09T12:00:00Z', wrapped.updated_at) + self.assertIsNone(wrapped.disabled_reason) + + def test_service_from_openstacksdk_disabled(self): + """Test Service.from_openstacksdk with disabled service.""" + service_id = utils.generate_uuid() + sdk_service = self.create_openstacksdk_service( + id=service_id, + status='disabled', + state='down', + disabled_reason='maintenance' + ) + + wrapped = nova_helper.Service.from_openstacksdk(sdk_service) + + self.assertEqual('disabled', wrapped.status) + self.assertEqual('down', wrapped.state) + self.assertEqual('maintenance', wrapped.disabled_reason) + class TestServerMigrationWrapper(test_utils.NovaResourcesMixin, base.TestCase): """Test suite for the ServerMigration dataclass.""" @@ -1849,3 +2160,34 @@ class TestServerMigrationWrapper(test_utils.NovaResourcesMixin, base.TestCase): # Compare with non-ServerMigration object self.assertNotEqual(mig1a, "not-a-migration") + + def test_migration_from_openstacksdk_basic_properties(self): + """Test ServerMigration.from_openstacksdk with basic properties.""" + migration_id = utils.generate_uuid() + sdk_migration = self.create_openstacksdk_migration( + id=migration_id + ) + + wrapped = nova_helper.ServerMigration.from_openstacksdk(sdk_migration) + self.assertEqual(migration_id, wrapped.id) + + def test_migration_equality_from_openstacksdk(self): + """Test ServerMigration dataclass equality comparison.""" + migration_id1 = utils.generate_uuid() + migration_id2 = utils.generate_uuid() + + mig1a = nova_helper.ServerMigration.from_openstacksdk( + self.create_openstacksdk_migration(id=migration_id1)) + mig1b = nova_helper.ServerMigration.from_openstacksdk( + self.create_openstacksdk_migration(id=migration_id1)) + mig2 = nova_helper.ServerMigration.from_openstacksdk( + self.create_openstacksdk_migration(id=migration_id2)) + + # Same ID and attributes should be equal + self.assertEqual(mig1a, mig1b) + + # Different ID should not be equal + self.assertNotEqual(mig1a, mig2) + + # Compare with non-ServerMigration object + self.assertNotEqual(mig1a, "not-a-migration") diff --git a/watcher/tests/unit/common/utils.py b/watcher/tests/unit/common/utils.py index fa2cbf95f..7273ff21d 100644 --- a/watcher/tests/unit/common/utils.py +++ b/watcher/tests/unit/common/utils.py @@ -21,6 +21,12 @@ from novaclient.v2 import hypervisors from novaclient.v2 import server_migrations from novaclient.v2 import servers from novaclient.v2 import services +from openstack.compute.v2 import aggregate +from openstack.compute.v2 import flavor +from openstack.compute.v2 import hypervisor +from openstack.compute.v2 import server +from openstack.compute.v2 import server_migration +from openstack.compute.v2 import service class NovaResourcesMixin: @@ -144,3 +150,123 @@ class NovaResourcesMixin: migration_info.update(kwargs) return server_migrations.ServerMigration( server_migrations.ServerMigrationsManager, info=migration_info) + + def create_openstacksdk_server(self, **kwargs): + """Create a real OpenStackSDK Server object. + + :param kwargs: server attributes + :returns: openstack.compute.v2.server.Server object + """ + server_info = { + 'id': kwargs.pop('id', 'test_id'), + 'name': kwargs.pop('name', 'test-server'), + 'created_at': kwargs.pop('created_at', '2026-01-09T12:00:00Z'), + 'compute_host': kwargs.pop('compute_host', None), + 'vm_state': kwargs.pop('vm_state', None), + 'task_state': kwargs.pop('task_state', None), + 'power_state': kwargs.pop('power_state', None), + 'status': kwargs.pop('status', 'ACTIVE'), + 'flavor': kwargs.pop('flavor', {'id': 'flavor-1'}), + 'project_id': kwargs.pop('project_id', 'test-tenant-id'), + 'is_locked': kwargs.pop('is_locked', False), + 'metadata': kwargs.pop('metadata', {}), + 'availability_zone': kwargs.pop('availability_zone', None), + 'pinned_availability_zone': kwargs.pop( + 'pinned_availability_zone', None), + } + server_info.update(kwargs) + return server.Server(**server_info) + + def create_openstacksdk_hypervisor(self, **kwargs): + """Create a real OpenStackSDK Hypervisor object. + + :param kwargs: hypervisor attributes + :returns: openstack.compute.v2.hypervisor.Hypervisor object + """ + name = kwargs.pop('name', 'hypervisor-hostname') + hypervisor_info = { + 'id': kwargs.pop('id', 'hypervisor-id'), + 'name': name, + 'hypervisor_type': kwargs.pop('hypervisor_type', 'QEMU'), + 'state': kwargs.pop('state', 'up'), + 'status': kwargs.pop('status', 'enabled'), + 'vcpus': kwargs.pop('vcpus', 16), + 'vcpus_used': kwargs.pop('vcpus_used', 4), + 'memory_size': kwargs.pop('memory_size', 32768), + 'memory_used': kwargs.pop('memory_used', 8192), + 'local_disk_size': kwargs.pop('local_disk_size', 500), + 'local_disk_used': kwargs.pop('local_disk_used', 100), + 'service_details': kwargs.pop( + 'service_details', {'host': name, 'id': 1} + ), + 'servers': kwargs.pop('servers', None), + } + hypervisor_info.update(kwargs) + return hypervisor.Hypervisor(**hypervisor_info) + + def create_openstacksdk_flavor(self, **kwargs): + """Create a real OpenStackSDK Flavor object. + + :param kwargs: flavor attributes + :returns: openstack.compute.v2.flavor.Flavor object + """ + flavor_info = { + 'id': kwargs.pop('id', 'flavor-id'), + 'name': kwargs.pop('name', 'm1.small'), + 'vcpus': kwargs.pop('vcpus', 2), + 'ram': kwargs.pop('ram', 2048), + 'disk': kwargs.pop('disk', 20), + 'ephemeral': kwargs.pop('ephemeral', 0), + 'swap': kwargs.pop('swap', 0), + 'is_public': kwargs.pop('is_public', True), + 'extra_specs': kwargs.pop('extra_specs', {}), + } + flavor_info.update(kwargs) + return flavor.Flavor(**flavor_info) + + def create_openstacksdk_aggregate(self, **kwargs): + """Create a real OpenStackSDK Aggregate object. + + :param kwargs: aggregate attributes + :returns: openstack.compute.v2.aggregate.Aggregate object + """ + aggregate_info = { + 'id': kwargs.pop('id', 'aggregate-id'), + 'name': kwargs.pop('name', 'test-aggregate'), + 'availability_zone': kwargs.pop('availability_zone', None), + 'hosts': kwargs.pop('hosts', []), + 'metadata': kwargs.pop('metadata', {}), + } + aggregate_info.update(kwargs) + return aggregate.Aggregate(**aggregate_info) + + def create_openstacksdk_service(self, **kwargs): + """Create a real OpenStackSDK Service object. + + :param kwargs: service attributes + :returns: openstack.compute.v2.service.Service object + """ + service_info = { + 'id': kwargs.pop('id', 'service-id'), + 'binary': kwargs.pop('binary', 'nova-compute'), + 'host': kwargs.pop('host', 'compute-1'), + 'availability_zone': kwargs.pop('availability_zone', 'nova'), + 'status': kwargs.pop('status', 'enabled'), + 'state': kwargs.pop('state', 'up'), + 'updated_at': kwargs.pop('updated_at', '2026-01-09T12:00:00Z'), + 'disabled_reason': kwargs.pop('disabled_reason', None), + } + service_info.update(kwargs) + return service.Service(**service_info) + + def create_openstacksdk_migration(self, **kwargs): + """Create a real OpenStackSDK ServerMigration object. + + :param kwargs: migration attributes + :returns: openstack.compute.v2.server_migration.ServerMigration object + """ + migration_info = { + 'id': kwargs.pop('id', 'migration-id'), + } + migration_info.update(kwargs) + return server_migration.ServerMigration(**migration_info)