diff --git a/novaclient/__init__.py b/novaclient/__init__.py index bfa75532f..43e5f2567 100644 --- a/novaclient/__init__.py +++ b/novaclient/__init__.py @@ -14,5 +14,10 @@ import pbr.version +from novaclient import api_versions + __version__ = pbr.version.VersionInfo('python-novaclient').version_string() + +API_MIN_VERSION = api_versions.APIVersion("2.1") +API_MAX_VERSION = api_versions.APIVersion("2.1") diff --git a/novaclient/api_versions.py b/novaclient/api_versions.py index e50edea2a..ba4a56db8 100644 --- a/novaclient/api_versions.py +++ b/novaclient/api_versions.py @@ -19,6 +19,7 @@ import re from oslo_utils import strutils +import novaclient from novaclient import exceptions from novaclient.i18n import _, _LW from novaclient import utils @@ -229,6 +230,75 @@ def get_api_version(version_string): return api_version +def _get_server_version_range(client): + version = client.versions.get_current() + + if not hasattr(version, 'version') or not version.version: + return APIVersion(), APIVersion() + + return APIVersion(version.min_version), APIVersion(version.version) + + +def discover_version(client, requested_version): + """Returns latest version supported by both API and client. + + :param client: client object + :returns: APIVersion + """ + + server_start_version, server_end_version = _get_server_version_range( + client) + + if (not requested_version.is_latest() and + requested_version != APIVersion('2.0')): + if server_start_version.is_null() and server_end_version.is_null(): + raise exceptions.UnsupportedVersion( + _("Server doesn't support microversions")) + if not requested_version.matches(server_start_version, + server_end_version): + raise exceptions.UnsupportedVersion( + _("The specified version isn't supported by server. The valid " + "version range is '%(min)s' to '%(max)s'") % { + "min": server_start_version.get_string(), + "max": server_end_version.get_string()}) + return requested_version + + if requested_version == APIVersion('2.0'): + if (server_start_version == APIVersion('2.1') or + (server_start_version.is_null() and + server_end_version.is_null())): + return APIVersion('2.0') + else: + raise exceptions.UnsupportedVersion( + _("The server isn't backward compatible with Nova V2 REST " + "API")) + + if server_start_version.is_null() and server_end_version.is_null(): + return APIVersion('2.0') + elif novaclient.API_MIN_VERSION > server_end_version: + raise exceptions.UnsupportedVersion( + _("Server version is too old. The client valid version range is " + "'%(client_min)s' to '%(client_max)s'. The server valid version " + "range is '%(server_min)s' to '%(server_max)s'.") % { + 'client_min': novaclient.API_MIN_VERSION.get_string(), + 'client_max': novaclient.API_MAX_VERSION.get_string(), + 'server_min': server_start_version.get_string(), + 'server_max': server_end_version.get_string()}) + elif novaclient.API_MAX_VERSION < server_start_version: + raise exceptions.UnsupportedVersion( + _("Server version is too new. The client valid version range is " + "'%(client_min)s' to '%(client_max)s'. The server valid version " + "range is '%(server_min)s' to '%(server_max)s'.") % { + 'client_min': novaclient.API_MIN_VERSION.get_string(), + 'client_max': novaclient.API_MAX_VERSION.get_string(), + 'server_min': server_start_version.get_string(), + 'server_max': server_end_version.get_string()}) + elif novaclient.API_MAX_VERSION <= server_end_version: + return novaclient.API_MAX_VERSION + elif server_end_version < novaclient.API_MAX_VERSION: + return server_end_version + + def update_headers(headers, api_version): """Set 'X-OpenStack-Nova-API-Version' header if api_version is not null""" diff --git a/novaclient/shell.py b/novaclient/shell.py index acda8bf04..42fcbdcda 100644 --- a/novaclient/shell.py +++ b/novaclient/shell.py @@ -403,8 +403,8 @@ class OpenStackComputeShell(object): metavar='<compute-api-ver>', default=cliutils.env('OS_COMPUTE_API_VERSION', default=DEFAULT_OS_COMPUTE_API_VERSION), - help=_('Accepts X, X.Y (where X is major and Y is minor part), ' - 'defaults to env[OS_COMPUTE_API_VERSION].')) + help=_('Accepts X, X.Y (where X is major and Y is minor part) or ' + '"X.latest", defaults to env[OS_COMPUTE_API_VERSION].')) parser.add_argument( '--os_compute_api_version', help=argparse.SUPPRESS) @@ -547,18 +547,6 @@ class OpenStackComputeShell(object): def main(self, argv): # Parse args once to find version and debug settings parser = self.get_base_parser() - (options, args) = parser.parse_known_args(argv) - self.setup_debugging(options.debug) - - # Discover available auth plugins - novaclient.auth_plugin.discover_auth_systems() - - api_version = api_versions.get_api_version( - options.os_compute_api_version) - - # build available subcommands based on version - self.extensions = self._discover_extensions(api_version) - self._run_extension_hooks('__pre_parse_args__') # NOTE(dtroyer): Hackery to handle --endpoint_type due to argparse # thinking usage-list --end is ambiguous; but it @@ -568,24 +556,17 @@ class OpenStackComputeShell(object): spot = argv.index('--endpoint_type') argv[spot] = '--endpoint-type' - subcommand_parser = self.get_subcommand_parser( - api_version, do_help=("help" in args)) - self.parser = subcommand_parser + (args, args_list) = parser.parse_known_args(argv) - if options.help or not argv: - subcommand_parser.print_help() - return 0 + self.setup_debugging(args.debug) + self.extensions = [] + do_help = ('help' in argv) or not argv - args = subcommand_parser.parse_args(argv) - self._run_extension_hooks('__post_parse_args__', args) + # Discover available auth plugins + novaclient.auth_plugin.discover_auth_systems() - # Short-circuit and deal with help right away. - if args.func == self.do_help: - self.do_help(args) - return 0 - elif args.func == self.do_bash_completion: - self.do_bash_completion(args) - return 0 + api_version = api_versions.get_api_version( + args.os_compute_api_version) os_username = args.os_username os_user_id = args.os_user_id @@ -631,13 +612,13 @@ class OpenStackComputeShell(object): endpoint_type += 'URL' if not service_type: - service_type = (cliutils.get_service_type(args.func) or - DEFAULT_NOVA_SERVICE_TYPE) + # Note(alex_xu): We need discover version first, so if there isn't + # service type specified, we use default nova service type. + service_type = DEFAULT_NOVA_SERVICE_TYPE # If we have an auth token but no management_url, we must auth anyway. # Expired tokens are handled by client.py:_cs_request - must_auth = not (cliutils.isunauthenticated(args.func) - or (auth_token and management_url)) + must_auth = not (auth_token and management_url) # Do not use Keystone session for cases with no session support. The # presence of auth_plugin means os_auth_system is present and is not @@ -648,7 +629,7 @@ class OpenStackComputeShell(object): # FIXME(usrleon): Here should be restrict for project id same as # for os_username or os_password but for compatibility it is not. - if must_auth: + if must_auth and not do_help: if auth_plugin: auth_plugin.parse_opts(args) @@ -702,8 +683,8 @@ class OpenStackComputeShell(object): project_domain_id=args.os_project_domain_id, project_domain_name=args.os_project_domain_name) - if not any([args.os_tenant_id, args.os_tenant_name, - args.os_project_id, args.os_project_name]): + if not do_help and not any([args.os_tenant_id, args.os_tenant_name, + args.os_project_id, args.os_project_name]): raise exc.CommandError(_("You must provide a project name or" " project id via --os-project-name," " --os-project-id, env[OS_PROJECT_ID]" @@ -711,11 +692,77 @@ class OpenStackComputeShell(object): " use os-project and os-tenant" " interchangeably.")) - if not os_auth_url: + if not os_auth_url and not do_help: raise exc.CommandError( _("You must provide an auth url " "via either --os-auth-url or env[OS_AUTH_URL]")) + # This client is just used to discover api version. Version API needn't + # microversion, so we just pass version 2 at here. + self.cs = client.Client( + api_versions.APIVersion("2.0"), + os_username, os_password, os_tenant_name, + tenant_id=os_tenant_id, user_id=os_user_id, + auth_url=os_auth_url, insecure=insecure, + region_name=os_region_name, endpoint_type=endpoint_type, + extensions=self.extensions, service_type=service_type, + service_name=service_name, auth_system=os_auth_system, + auth_plugin=auth_plugin, auth_token=auth_token, + volume_service_name=volume_service_name, + timings=args.timings, bypass_url=bypass_url, + os_cache=os_cache, http_log_debug=args.debug, + cacert=cacert, timeout=timeout, + session=keystone_session, auth=keystone_auth) + + if not do_help: + if not api_version.is_latest(): + if api_version > api_versions.APIVersion("2.0"): + if not api_version.matches(novaclient.API_MIN_VERSION, + novaclient.API_MAX_VERSION): + raise exc.CommandError( + _("The specified version isn't supported by " + "client. The valid version range is '%(min)s' " + "to '%(max)s'") % { + "min": novaclient.API_MIN_VERSION.get_string(), + "max": novaclient.API_MAX_VERSION.get_string()} + ) + api_version = api_versions.discover_version(self.cs, api_version) + + # build available subcommands based on version + self.extensions = self._discover_extensions(api_version) + self._run_extension_hooks('__pre_parse_args__') + + subcommand_parser = self.get_subcommand_parser( + api_version, do_help=do_help) + self.parser = subcommand_parser + + if args.help or not argv: + subcommand_parser.print_help() + return 0 + + args = subcommand_parser.parse_args(argv) + self._run_extension_hooks('__post_parse_args__', args) + + # Short-circuit and deal with help right away. + if args.func == self.do_help: + self.do_help(args) + return 0 + elif args.func == self.do_bash_completion: + self.do_bash_completion(args) + return 0 + + if not args.service_type: + service_type = (cliutils.get_service_type(args.func) or + DEFAULT_NOVA_SERVICE_TYPE) + + if cliutils.isunauthenticated(args.func): + # NOTE(alex_xu): We need authentication for discover microversion. + # But the subcommands may needn't it. If the subcommand needn't, + # we clear the session arguements. + keystone_session = None + keystone_auth = None + + # Recreate client object with discovered version. self.cs = client.Client( api_version, os_username, os_password, os_tenant_name, @@ -727,7 +774,7 @@ class OpenStackComputeShell(object): auth_plugin=auth_plugin, auth_token=auth_token, volume_service_name=volume_service_name, timings=args.timings, bypass_url=bypass_url, - os_cache=os_cache, http_log_debug=options.debug, + os_cache=os_cache, http_log_debug=args.debug, cacert=cacert, timeout=timeout, session=keystone_session, auth=keystone_auth) diff --git a/novaclient/tests/unit/test_api_versions.py b/novaclient/tests/unit/test_api_versions.py index 2db3a8859..e3c204de4 100644 --- a/novaclient/tests/unit/test_api_versions.py +++ b/novaclient/tests/unit/test_api_versions.py @@ -1,4 +1,4 @@ -# Copyright 2015 Mirantis +# Copyright 2016 Mirantis # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -15,9 +15,11 @@ import mock +import novaclient from novaclient import api_versions from novaclient import exceptions from novaclient.tests.unit import utils +from novaclient.v2 import versions class APIVersionTestCase(utils.TestCase): @@ -247,3 +249,88 @@ class WrapsTestCase(utils.TestCase): some_func(obj, *some_args, **some_kwargs) checker.assert_called_once_with(*((obj,) + some_args), **some_kwargs) + + +class DiscoverVersionTestCase(utils.TestCase): + def setUp(self): + super(DiscoverVersionTestCase, self).setUp() + self.orig_max = novaclient.API_MAX_VERSION + self.orig_min = novaclient.API_MIN_VERSION + self.addCleanup(self._clear_fake_version) + + def _clear_fake_version(self): + novaclient.API_MAX_VERSION = self.orig_max + novaclient.API_MIN_VERSION = self.orig_min + + def test_server_is_too_new(self): + fake_client = mock.MagicMock() + fake_client.versions.get_current.return_value = mock.MagicMock( + version="2.7", min_version="2.4") + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.3") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.discover_version, fake_client, + api_versions.APIVersion('2.latest')) + + def test_server_is_too_old(self): + fake_client = mock.MagicMock() + fake_client.versions.get_current.return_value = mock.MagicMock( + version="2.7", min_version="2.4") + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.10") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.9") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.discover_version, fake_client, + api_versions.APIVersion('2.latest')) + + def test_server_end_version_is_the_latest_one(self): + fake_client = mock.MagicMock() + fake_client.versions.get_current.return_value = mock.MagicMock( + version="2.7", min_version="2.4") + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.11") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + + self.assertEqual( + "2.7", + api_versions.discover_version( + fake_client, + api_versions.APIVersion('2.latest')).get_string()) + + def test_client_end_version_is_the_latest_one(self): + fake_client = mock.MagicMock() + fake_client.versions.get_current.return_value = mock.MagicMock( + version="2.16", min_version="2.4") + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.11") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + + self.assertEqual( + "2.11", + api_versions.discover_version( + fake_client, + api_versions.APIVersion('2.latest')).get_string()) + + def test_server_without_microversion(self): + fake_client = mock.MagicMock() + fake_client.versions.get_current.return_value = mock.MagicMock( + version='', min_version='') + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.11") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + + self.assertEqual( + "2.0", + api_versions.discover_version( + fake_client, + api_versions.APIVersion('2.latest')).get_string()) + + def test_server_without_microversion_and_no_version_field(self): + fake_client = mock.MagicMock() + fake_client.versions.get_current.return_value = versions.Version( + None, {}) + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.11") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + + self.assertEqual( + "2.0", + api_versions.discover_version( + fake_client, + api_versions.APIVersion('2.latest')).get_string()) diff --git a/novaclient/tests/unit/test_shell.py b/novaclient/tests/unit/test_shell.py index 1caa56cbd..2209f6996 100644 --- a/novaclient/tests/unit/test_shell.py +++ b/novaclient/tests/unit/test_shell.py @@ -104,6 +104,19 @@ class ShellTest(utils.TestCase): self.nc_util = mock.patch( 'novaclient.openstack.common.cliutils.isunauthenticated').start() self.nc_util.return_value = False + self.mock_server_version_range = mock.patch( + 'novaclient.api_versions._get_server_version_range').start() + self.mock_server_version_range.return_value = ( + novaclient.API_MIN_VERSION, + novaclient.API_MIN_VERSION) + self.orig_max_ver = novaclient.API_MAX_VERSION + self.orig_min_ver = novaclient.API_MIN_VERSION + self.addCleanup(self._clear_fake_version) + self.addCleanup(mock.patch.stopall) + + def _clear_fake_version(self): + novaclient.API_MAX_VERSION = self.orig_max_ver + novaclient.API_MIN_VERSION = self.orig_min_ver def shell(self, argstr, exitcodes=(0,)): orig = sys.stdout @@ -168,6 +181,7 @@ class ShellTest(utils.TestCase): matchers.MatchesRegex(r, re.DOTALL | re.MULTILINE)) def test_help_no_options(self): + self.make_env() required = [ '.*?^usage: ', '.*?^\s+root-password\s+Change the admin password', @@ -179,6 +193,7 @@ class ShellTest(utils.TestCase): matchers.MatchesRegex(r, re.DOTALL | re.MULTILINE)) def test_bash_completion(self): + self.make_env() stdout, stderr = self.shell('bash-completion') # just check we have some output required = [ @@ -403,6 +418,79 @@ class ShellTest(utils.TestCase): keyring_saver = mock_client_instance.client.keyring_saver self.assertIsInstance(keyring_saver, novaclient.shell.SecretsHelper) + @mock.patch('novaclient.client.Client') + def test_microversion_with_latest(self, mock_client): + self.make_env() + novaclient.API_MAX_VERSION = api_versions.APIVersion('2.3') + self.mock_server_version_range.return_value = ( + api_versions.APIVersion("2.1"), api_versions.APIVersion("2.3")) + self.shell('--os-compute-api-version 2.latest list') + client_args = mock_client.call_args_list[1][0] + self.assertEqual(api_versions.APIVersion("2.3"), client_args[0]) + + @mock.patch('novaclient.client.Client') + def test_microversion_with_specified_version(self, mock_client): + self.make_env() + self.mock_server_version_range.return_value = ( + api_versions.APIVersion("2.10"), api_versions.APIVersion("2.100")) + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.100") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.90") + self.shell('--os-compute-api-version 2.99 list') + client_args = mock_client.call_args_list[1][0] + self.assertEqual(api_versions.APIVersion("2.99"), client_args[0]) + + @mock.patch('novaclient.client.Client') + def test_microversion_with_specified_version_out_of_range(self, + mock_client): + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.100") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.90") + self.assertRaises(exceptions.CommandError, + self.shell, '--os-compute-api-version 2.199 list') + + @mock.patch('novaclient.client.Client') + def test_microversion_with_v2_and_v2_1_server(self, mock_client): + self.make_env() + self.mock_server_version_range.return_value = ( + api_versions.APIVersion('2.1'), api_versions.APIVersion('2.3')) + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.100") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + self.shell('--os-compute-api-version 2 list') + client_args = mock_client.call_args_list[1][0] + self.assertEqual(api_versions.APIVersion("2.0"), client_args[0]) + + @mock.patch('novaclient.client.Client') + def test_microversion_with_v2_and_v2_server(self, mock_client): + self.make_env() + self.mock_server_version_range.return_value = ( + api_versions.APIVersion(), api_versions.APIVersion()) + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.100") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + self.shell('--os-compute-api-version 2 list') + client_args = mock_client.call_args_list[1][0] + self.assertEqual(api_versions.APIVersion("2.0"), client_args[0]) + + @mock.patch('novaclient.client.Client') + def test_microversion_with_v2_without_server_compatible(self, mock_client): + self.make_env() + self.mock_server_version_range.return_value = ( + api_versions.APIVersion('2.2'), api_versions.APIVersion('2.3')) + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.100") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + self.assertRaises( + exceptions.UnsupportedVersion, + self.shell, '--os-compute-api-version 2 list') + + def test_microversion_with_specific_version_without_microversions(self): + self.make_env() + self.mock_server_version_range.return_value = ( + api_versions.APIVersion(), api_versions.APIVersion()) + novaclient.API_MAX_VERSION = api_versions.APIVersion("2.100") + novaclient.API_MIN_VERSION = api_versions.APIVersion("2.1") + self.assertRaises( + exceptions.UnsupportedVersion, + self.shell, + '--os-compute-api-version 2.3 list') + class TestLoadVersionedActions(utils.TestCase): diff --git a/novaclient/tests/unit/v2/fakes.py b/novaclient/tests/unit/v2/fakes.py index f468e3e48..9433f6799 100644 --- a/novaclient/tests/unit/v2/fakes.py +++ b/novaclient/tests/unit/v2/fakes.py @@ -2260,7 +2260,14 @@ class FakeSessionMockClient(base_client.SessionClient, FakeHTTPClient): self.callstack = [] self.auth = mock.Mock() self.session = mock.Mock() + self.session.get_endpoint.return_value = FakeHTTPClient.get_endpoint( + self) self.service_type = 'service_type' + self.service_name = None + self.endpoint_override = None + self.interface = None + self.region_name = None + self.version = None self.auth.get_auth_ref.return_value.project_id = 'tenant_id'