diff --git a/novaclient/__init__.py b/novaclient/__init__.py index 8b6111de1..304660c93 100644 --- a/novaclient/__init__.py +++ b/novaclient/__init__.py @@ -25,4 +25,4 @@ API_MIN_VERSION = api_versions.APIVersion("2.1") # when client supported the max version, and bumped sequentially, otherwise # the client may break due to server side new version may include some # backward incompatible change. -API_MAX_VERSION = api_versions.APIVersion("2.72") +API_MAX_VERSION = api_versions.APIVersion("2.73") diff --git a/novaclient/tests/unit/fixture_data/servers.py b/novaclient/tests/unit/fixture_data/servers.py index 6df4129bd..d3354469a 100644 --- a/novaclient/tests/unit/fixture_data/servers.py +++ b/novaclient/tests/unit/fixture_data/servers.py @@ -454,6 +454,8 @@ class V1(Base): pass elif action == 'migrate': return None + elif action == 'lock': + return None elif action == 'rebuild': body = body[action] adminPass = body.get('adminPass', 'randompassword') diff --git a/novaclient/tests/unit/v2/fakes.py b/novaclient/tests/unit/v2/fakes.py index a32e22582..29ec88877 100644 --- a/novaclient/tests/unit/v2/fakes.py +++ b/novaclient/tests/unit/v2/fakes.py @@ -774,7 +774,7 @@ class FakeSessionClient(base_client.SessionClient): none_actions = ['revertResize', 'os-stop', 'os-start', 'forceDelete', 'restore', 'pause', 'unpause', 'unlock', - 'unrescue', 'resume', 'suspend', 'lock', 'shelve', + 'unrescue', 'resume', 'suspend', 'shelve', 'shelveOffload', 'unshelve', 'resetNetwork'] type_actions = ['os-getVNCConsole', 'os-getSPICEConsole', 'os-getRDPConsole'] @@ -836,6 +836,22 @@ class FakeSessionClient(base_client.SessionClient): # host can be optional expected.add('host') assert set(body[action].keys()) == expected + elif action == 'lock': + if self.api_version < api_versions.APIVersion("2.73"): + assert body[action] is None + else: + # In 2.73 and above, we allow body to be one of these: + # a) {'lock': None} + # b) {'lock': {}} + # c) {'lock': {locked_reason': 'blah'}} + if body[action] is not None: + expected = set() + if 'locked_reason' in body[action].keys(): + # reason can be optional + expected.add('locked_reason') + assert set(body[action].keys()) == expected + else: + assert body[action] is None elif action == 'rebuild': body = body[action] adminPass = body.get('adminPass', 'randompassword') diff --git a/novaclient/tests/unit/v2/test_servers.py b/novaclient/tests/unit/v2/test_servers.py index 529fd389d..43dbf2c28 100644 --- a/novaclient/tests/unit/v2/test_servers.py +++ b/novaclient/tests/unit/v2/test_servers.py @@ -1703,3 +1703,26 @@ class ServersV268Test(ServersV267Test): ex = self.assertRaises(TypeError, self.cs.servers.live_migrate, host='hostname', force=True) self.assertIn('force', six.text_type(ex)) + + +class ServersV273Test(ServersV268Test): + + api_version = "2.73" + + def test_lock_server(self): + s = self.cs.servers.get(1234) + ret = s.lock() + self.assert_request_id(ret, fakes.FAKE_REQUEST_ID_LIST) + self.assert_called('POST', '/servers/1234/action', + {'lock': None}) + ret = s.lock(reason='zombie-apocalypse') + self.assert_request_id(ret, fakes.FAKE_REQUEST_ID_LIST) + self.assert_called('POST', '/servers/1234/action', + {'lock': {'locked_reason': 'zombie-apocalypse'}}) + + def test_lock_server_pre_273_fails_with_reason(self): + self.cs.api_version = api_versions.APIVersion('2.72') + s = self.cs.servers.get(1234) + e = self.assertRaises(TypeError, + s.lock, reason='blah') + self.assertIn("unexpected keyword argument 'reason'", six.text_type(e)) diff --git a/novaclient/tests/unit/v2/test_shell.py b/novaclient/tests/unit/v2/test_shell.py index 447a7229f..4f2ee2582 100644 --- a/novaclient/tests/unit/v2/test_shell.py +++ b/novaclient/tests/unit/v2/test_shell.py @@ -2092,6 +2092,24 @@ class ShellTest(utils.TestCase): self.run_command('lock sample-server') self.assert_called('POST', '/servers/1234/action', {'lock': None}) + def test_lock_pre_v273(self): + exp = self.assertRaises(SystemExit, + self.run_command, + 'lock sample-server --reason zombies', + api_version='2.72') + self.assertIn('2', six.text_type(exp)) + + def test_lock_v273(self): + self.run_command('lock sample-server', + api_version='2.73') + self.assert_called('POST', '/servers/1234/action', + {'lock': None}) + + self.run_command('lock sample-server --reason zombies', + api_version='2.73') + self.assert_called('POST', '/servers/1234/action', + {'lock': {'locked_reason': 'zombies'}}) + def test_unlock(self): self.run_command('unlock sample-server') self.assert_called('POST', '/servers/1234/action', {'unlock': None}) @@ -4280,6 +4298,22 @@ class ShellTest(utils.TestCase): self.assert_called('GET', '/servers/9015', pos=2) self.assert_called('GET', '/v2/images/%s' % FAKE_UUID_2, pos=3) + def test_list_pre_v273(self): + exp = self.assertRaises(SystemExit, + self.run_command, + 'list --locked t', + api_version='2.72') + self.assertEqual(2, exp.code) + + def test_list_v273(self): + self.run_command('list --locked t', api_version='2.73') + self.assert_called('GET', '/servers/detail?locked=t') + + def test_list_v273_with_sort_key_dir(self): + self.run_command('list --sort locked:asc', api_version='2.73') + self.assert_called( + 'GET', '/servers/detail?sort_dir=asc&sort_key=locked') + class PollForStatusTestCase(utils.TestCase): @mock.patch("novaclient.v2.shell.time") diff --git a/novaclient/v2/servers.py b/novaclient/v2/servers.py index a37c1b387..afbd5536c 100644 --- a/novaclient/v2/servers.py +++ b/novaclient/v2/servers.py @@ -214,6 +214,7 @@ class Server(base.Resource): """ return self.manager.unpause(self) + @api_versions.wraps("2.0", "2.72") def lock(self): """ Lock -- Lock the instance from certain operations. @@ -222,6 +223,16 @@ class Server(base.Resource): """ return self.manager.lock(self) + @api_versions.wraps("2.73") + def lock(self, reason=None): + """ + Lock -- Lock the instance from certain operations. + + :param reason: (Optional) The lock reason. + :returns: An instance of novaclient.base.TupleWithMeta + """ + return self.manager.lock(self, reason=reason) + def unlock(self): """ Unlock -- Remove instance lock. @@ -1097,6 +1108,7 @@ class ServerManager(base.BootingManagerWithFind): """ return self._action('unpause', server, None) + @api_versions.wraps("2.0", "2.72") def lock(self, server): """ Lock the server. @@ -1106,6 +1118,22 @@ class ServerManager(base.BootingManagerWithFind): """ return self._action('lock', server, None) + @api_versions.wraps("2.73") + def lock(self, server, reason=None): + """ + Lock the server. + + :param server: The :class:`Server` (or its ID) to lock + :param reason: (Optional) The lock reason. + :returns: An instance of novaclient.base.TupleWithMeta + """ + info = None + + if reason: + info = {'locked_reason': reason} + + return self._action('lock', server, info) + def unlock(self, server): """ Unlock the server. diff --git a/novaclient/v2/shell.py b/novaclient/v2/shell.py index f7d59dfaa..4e8a730d3 100644 --- a/novaclient/v2/shell.py +++ b/novaclient/v2/shell.py @@ -1563,6 +1563,13 @@ def _print_flavor(flavor): "case is 'NOT(t1 OR t2)'. Tags must be separated by commas: " "--not-tags-any "), start_version="2.26") +@utils.arg( + '--locked', + dest='locked', + metavar='', + default=None, + help=_('Display servers based on their locked value'), + start_version="2.73") def do_list(cs, args): """List servers.""" imageid = None @@ -1639,6 +1646,11 @@ def do_list(cs, args): raise exceptions.CommandError(_('Invalid changes-before value: %s') % search_opts['changes-before']) + # In microversion 2.73 we added ``locked`` option in server details. + have_added_locked = cs.api_version >= api_versions.APIVersion('2.73') + if have_added_locked and args.locked: + search_opts['locked'] = args.locked + servers = cs.servers.list(detailed=detailed, search_opts=search_opts, sort_keys=sort_keys, @@ -2155,12 +2167,23 @@ def do_start(cs, args): _("Unable to start the specified server(s).")) +# From microversion 2.73, we can specify a reason for locking the server. @utils.arg('server', metavar='', help=_('Name or ID of server.')) +@utils.arg( + '--reason', + metavar='', + help=_('Reason for locking the server.'), + start_version='2.73') def do_lock(cs, args): """Lock a server. A normal (non-admin) user will not be able to execute actions on a locked server. """ - _find_server(cs, args.server).lock() + update_kwargs = {} + if 'reason' in args and args.reason is not None: + update_kwargs['reason'] = args.reason + + server = _find_server(cs, args.server) + server.lock(**update_kwargs) @utils.arg('server', metavar='', help=_('Name or ID of server.')) diff --git a/releasenotes/notes/bp-add-locked-reason-3f136db97b820c73.yaml b/releasenotes/notes/bp-add-locked-reason-3f136db97b820c73.yaml new file mode 100644 index 000000000..60e19bfcf --- /dev/null +++ b/releasenotes/notes/bp-add-locked-reason-3f136db97b820c73.yaml @@ -0,0 +1,6 @@ +--- +features: + - Added a ``--reason`` option to ``nova lock`` command that enables users + to specify a reason when locking a server and a ``locked`` + filtering/sorting option to ``nova list`` command which enables users to + filter/sort servers based on their ``locked`` value in microversion 2.73.