Prepare to use openstacksdk instead of novaclient

Prepare Watcher to use openstacksdk. This patch introduces a new
function in the clients module to create a openstacksdk connection
either using a user token, an existing keystone session or a new
keystone session. Additionally, it adds a method to the newly introduced
wrapper classes to create nova server, hypervisors, flavors, etc from
the objects returned by the openstacksdk compute proxy.

This patch also deprecates the nova_client configuration options and
adds keystoneauth adapter configuration options into the nova conf
group, since that is required by the openstacksdk to create a
Connection.

Assisted-By: claude-code (claude-sonnet-4.5)

Change-Id: I7e297419243f16548a54e332609bbcbd19c3d758
Signed-off-by: jgilaber <jgilaber@redhat.com>
This commit is contained in:
jgilaber
2026-01-15 15:39:45 +01:00
parent 8e9ddcf80b
commit 88615ac2cd
11 changed files with 826 additions and 18 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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,

View File

@@ -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():

View File

@@ -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.')]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)