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 configuration options into the nova conf
group, since that is required by the openstacksdk to create a
Connection. The nova group will be the preferred section to configure
the connnection to nova, with the currently used watcher_clients_auth as
fallback to avoid upgrade impact. In the future, once all services are
accessed through the openstacksdk, the watcher_clients_auth section
should be removed.

Add a call to the configure_keystoneauth function in the the devstack
plugin to populate the [nova] section with the keystoneauth parameters.

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 18cb461373
commit 06e361e0aa
12 changed files with 933 additions and 19 deletions

View File

@@ -153,6 +153,7 @@ function create_watcher_conf {
configure_keystone_authtoken_middleware $WATCHER_CONF watcher
configure_keystone_authtoken_middleware $WATCHER_CONF watcher "watcher_clients_auth"
configure_keystoneauth $WATCHER_CONF watcher "nova"
if [ -n "$WATCHER_STATE_PATH" ]; then
iniset $WATCHER_CONF DEFAULT state_path "$WATCHER_STATE_PATH"

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

@@ -19,10 +19,13 @@ from gnocchiclient import client as gnclient
from ironicclient import client as irclient
from keystoneauth1 import adapter as ka_adapter
from keystoneauth1 import loading as ka_loading
from keystoneauth1 import session as ka_session
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 context
from watcher.common import exception
from watcher.common import utils
@@ -45,6 +48,61 @@ MIN_NOVA_API_VERSION = '2.56'
warnings.simplefilter("once")
def get_sdk_connection(
conf_group: str, session: ka_session.Session | None = None,
context: context.RequestContext | None = None,
interface: str | None = None, region_name: str | None = None
) -> connection.Connection:
"""Create and return an OpenStackSDK Connection object.
:param conf_group: String name of the conf group to get connection
information from.
: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.
:param interface: Interface to use when connecting to services.
:param region_name: Region name to use when connecting to services.
: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, conf_group
)
if context is not None:
if interface is None:
if "valid_interfaces" in CONF[conf_group]:
interface = CONF[conf_group].valid_interfaces[0]
elif "interface" in CONF[conf_group]:
interface = CONF[conf_group].interface
if region_name is None and "region_name" in CONF[conf_group]:
region_name = CONF[conf_group].region_name
# create a connection using the user's token if available
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[conf_group].auth_url,
region_name=region_name,
interface=interface
)
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, conf_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 +112,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.')
@@ -115,7 +173,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

@@ -22,6 +22,7 @@ import time
import uuid
from keystoneauth1 import exceptions as ksa_exc
from keystoneauth1 import loading as ks_loading
from novaclient import api_versions
from oslo_log import log
@@ -30,6 +31,7 @@ import novaclient.exceptions as nvexceptions
from watcher.common import clients
from watcher.common import exception
from watcher import conf
from watcher.conf import clients_auth
LOG = log.getLogger(__name__)
@@ -152,6 +154,31 @@ 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
"""
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:
@@ -222,6 +249,45 @@ 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 = []
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:
@@ -268,6 +334,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:
@@ -300,6 +386,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:
@@ -344,6 +445,25 @@ 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
"""
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:
@@ -368,16 +488,49 @@ 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:
def __init__(self, osc=None):
""":param osc: an OpenStackClients instance"""
self._config_overrides = False
self._override_deprecated_configs()
self._auth_group = 'nova'
self.osc = osc if osc else clients.OpenStackClients()
self.cinder = self.osc.cinder()
self.nova = self.osc.nova()
self._is_pinned_az_available = None
def _override_deprecated_configs(self):
if self._config_overrides:
return
if CONF.nova.valid_interfaces is None and CONF.nova.interface is None:
# NOTE(jgilaber): ensure the endpoint_type option from nova_client
# is processed and set with the right format in [nova]
# valid_interfaces, if the latter is not set in the configuration
endpoint_type = CONF.nova_client.endpoint_type.replace('URL', '')
CONF.set_override('valid_interfaces', [endpoint_type], 'nova')
nova_auth = ks_loading.load_auth_from_conf_options(CONF, 'nova')
if nova_auth is None:
# NOTE(jgilaber): if can't configure the auth from the values in
# [nova], use [watcher_clients_auth] as fallback
self._auth_group = clients_auth.WATCHER_CLIENTS_AUTH
self._config_overrides = True
def is_pinned_az_available(self):
"""Check if pinned AZ is available in GET /servers/detail response.
@@ -386,7 +539,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
@@ -1017,7 +1170,7 @@ class NovaHelper:
:param hostname: the hostname of the compute service to enable
:returns: True if service is now enabled, False otherwise
"""
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'
@@ -1038,7 +1191,7 @@ class NovaHelper:
:param reason: optional reason for disabling the service
:returns: True if service is now disabled, False otherwise
"""
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,13 +52,55 @@ 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 _deprecations():
# NOTE(jgilaber): match the adapter options that were previously passed
# through the [nova_client] group, except for endpoint_type which needs to
# be handled after the configuration is parsed because it might need a
# string manipulation in case the configuration contains a *URL value (e.g
# publicURL)
deprecations = {
'region_name': [cfg.DeprecatedOpt('region_name', 'nova_client')]
}
return deprecations
def register_opts(conf):
conf.register_group(nova)
conf.register_opts(NOVA_OPTS, group=nova)
deprecated_opts = _deprecations()
ks_loading.register_adapter_conf_options(
conf, nova.name,
deprecated_opts=deprecated_opts
)
ks_loading.register_session_conf_options(conf, nova.name,)
ks_loading.register_auth_conf_options(conf, nova.name)
def list_opts():
return [(nova, NOVA_OPTS)]
deprecated_opts = _deprecations()
return [(
nova,
NOVA_OPTS +
ks_loading.get_adapter_conf_options(
include_deprecated=False, deprecated_opts=deprecated_opts
) +
ks_loading.get_session_conf_options() +
ks_loading.get_auth_common_conf_options()
)]

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,13 @@ 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.'
),
deprecated_since='2026.1',
help=f"""
Version of Nova API to use in novaclient.
@@ -37,10 +45,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

@@ -24,13 +24,14 @@ from keystoneauth1 import loading as ka_loading
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'
@@ -51,6 +52,9 @@ class TestClients(base.TestCase):
# single method, so we do this instead
CONF.reset()
del CONF._groups[_AUTH_CONF_GROUP]
# register again the auth options so the group is not empty
ka_loading.register_auth_conf_options(CONF, _AUTH_CONF_GROUP)
ka_loading.register_session_conf_options(CONF, _AUTH_CONF_GROUP)
self.addCleanup(cleanup_conf_from_loading)
@@ -84,6 +88,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()
@@ -110,14 +117,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()
@@ -125,11 +132,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):
@@ -287,3 +294,91 @@ 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."""
def setUp(self):
self._register_watcher_clients_auth_opts()
return super().setUp()
@mock.patch('openstack.connection.Connection', autospec=True)
def test_get_sdk_connection_with_context(
self, mock_connect):
"""Test SDK connection creation with context."""
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(
'watcher_clients_auth', 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',
interface=None,
region_name=None
)
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(
'watcher_clients_auth', 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('watcher_clients_auth')
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

@@ -926,7 +926,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)
@@ -937,7 +937,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)
@@ -951,7 +951,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')
@@ -963,7 +963,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')
@@ -1489,6 +1489,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."""
@@ -1674,6 +1732,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."""
@@ -1775,6 +1938,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."""
@@ -1831,6 +2070,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."""
@@ -1927,6 +2197,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 TestHandleNovaError(base.TestCase):
"""Test suite for the handle_nova_error decorator."""
@@ -2088,3 +2399,94 @@ 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")
@mock.patch.object(clients.OpenStackClients, 'nova', autospec=True)
@mock.patch.object(clients.OpenStackClients, 'cinder', autospec=True)
class TestNovaHelperConfigOverrides(base.TestCase):
"""Test suite for the NovaHelper config override functionality.
Tests the deprecated config migration from [nova_client] to [nova] group.
"""
def test_endpoint_type_override_public_url(self, mock_cinder, mock_nova):
"""Test endpoint_type publicURL is converted to public."""
self.flags(endpoint_type='publicURL', group='nova_client')
nova_helper.NovaHelper()
self.assertEqual(['public'], CONF.nova.valid_interfaces)
def test_endpoint_type_override_internal_url(self, mock_cinder, mock_nova):
"""Test endpoint_type internalURL is converted to internal."""
self.flags(endpoint_type='internalURL', group='nova_client')
nova_helper.NovaHelper()
self.assertEqual(['internal'], CONF.nova.valid_interfaces)
def test_endpoint_type_override_admin_url(self, mock_cinder, mock_nova):
"""Test endpoint_type adminURL is converted to admin."""
self.flags(endpoint_type='adminURL', group='nova_client')
nova_helper.NovaHelper()
self.assertEqual(['admin'], CONF.nova.valid_interfaces)
def test_endpoint_type_override_without_url_suffix(
self, mock_cinder, mock_nova):
"""Test endpoint_type without URL suffix is preserved."""
self.flags(endpoint_type='public', group='nova_client')
nova_helper.NovaHelper()
self.assertEqual(['public'], CONF.nova.valid_interfaces)
def test_endpoint_type_override_internal_without_suffix(
self, mock_cinder, mock_nova):
"""Test endpoint_type internal without suffix is preserved."""
self.flags(endpoint_type='internal', group='nova_client')
nova_helper.NovaHelper()
self.assertEqual(['internal'], CONF.nova.valid_interfaces)
def test_endpoint_type_override_admin_without_suffix(
self, mock_cinder, mock_nova):
"""Test endpoint_type admin without suffix is preserved."""
self.flags(endpoint_type='admin', group='nova_client')
nova_helper.NovaHelper()
self.assertEqual(['admin'], CONF.nova.valid_interfaces)

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)