diff --git a/novaclient/__init__.py b/novaclient/__init__.py index 435ff9c9f..2103d5cea 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.56") +API_MAX_VERSION = api_versions.APIVersion("2.57") diff --git a/novaclient/exceptions.py b/novaclient/exceptions.py index 707aa889e..a7046d338 100644 --- a/novaclient/exceptions.py +++ b/novaclient/exceptions.py @@ -48,6 +48,7 @@ class UnsupportedAttribute(AttributeError): self.message = ( "'%(name)s' argument is only allowed since microversion " "%(start)s." % {"name": argument_name, "start": start_version}) + super(UnsupportedAttribute, self).__init__(self.message) class CommandError(Exception): diff --git a/novaclient/tests/functional/v2/test_quotas.py b/novaclient/tests/functional/v2/test_quotas.py index 1d4e6af31..effddf8a6 100644 --- a/novaclient/tests/functional/v2/test_quotas.py +++ b/novaclient/tests/functional/v2/test_quotas.py @@ -52,7 +52,7 @@ class TestQuotasNovaClient2_35(test_quotas.TestQuotasNovaClient): class TestQuotasNovaClient2_36(TestQuotasNovaClient2_35): """Nova quotas functional tests.""" - COMPUTE_API_VERSION = "2.latest" + COMPUTE_API_VERSION = "2.36" # The 2.36 microversion stops proxying network quota resources like # floating/fixed IPs and security groups/rules. @@ -61,3 +61,14 @@ class TestQuotasNovaClient2_36(TestQuotasNovaClient2_35): 'injected_file_content_bytes', 'injected_file_path_bytes', 'key_pairs', 'server_groups', 'server_group_members'] + + +class TestQuotasNovaClient2_57(TestQuotasNovaClient2_35): + """Nova quotas functional tests.""" + + COMPUTE_API_VERSION = "2.latest" + + # The 2.57 microversion deprecates injected_file* quotas. + _quota_resources = ['instances', 'cores', 'ram', + 'metadata_items', 'key_pairs', + 'server_groups', 'server_group_members'] diff --git a/novaclient/tests/unit/fixture_data/limits.py b/novaclient/tests/unit/fixture_data/limits.py index f9a86ff79..0e7986580 100644 --- a/novaclient/tests/unit/fixture_data/limits.py +++ b/novaclient/tests/unit/fixture_data/limits.py @@ -16,6 +16,13 @@ from novaclient.tests.unit.fixture_data import base class Fixture(base.Fixture): base_url = 'limits' + absolute = { + "maxTotalRAMSize": 51200, + "maxServerMeta": 5, + "maxImageMeta": 5, + "maxPersonality": 5, + "maxPersonalitySize": 10240 + } def setUp(self): super(Fixture, self).setUp() @@ -64,13 +71,7 @@ class Fixture(base.Fixture): ] } ], - "absolute": { - "maxTotalRAMSize": 51200, - "maxServerMeta": 5, - "maxImageMeta": 5, - "maxPersonality": 5, - "maxPersonalitySize": 10240 - }, + "absolute": self.absolute, }, } @@ -78,3 +79,13 @@ class Fixture(base.Fixture): self.requests_mock.get(self.url(), json=get_limits, headers=headers) + + +class Fixture2_57(Fixture): + """Fixture data for the 2.57 microversion where personality files are + deprecated. + """ + absolute = { + "maxTotalRAMSize": 51200, + "maxServerMeta": 5 + } diff --git a/novaclient/tests/unit/fixture_data/quotas.py b/novaclient/tests/unit/fixture_data/quotas.py index e3d179e26..1ffa8c265 100644 --- a/novaclient/tests/unit/fixture_data/quotas.py +++ b/novaclient/tests/unit/fixture_data/quotas.py @@ -57,6 +57,7 @@ class V1(base.Fixture): 'injected_file_content_bytes': 1, 'injected_file_path_bytes': 1, 'ram': 1, + 'fixed_ips': -1, 'floating_ips': 1, 'instances': 1, 'injected_files': 1, @@ -67,3 +68,20 @@ class V1(base.Fixture): 'server_groups': 1, 'server_group_members': 1 } + + +class V2_57(V1): + """2.57 fixture data where there are no injected file or network resources + """ + + def test_quota(self, tenant_id='test'): + return { + 'id': tenant_id, + 'metadata_items': 1, + 'ram': 1, + 'instances': 1, + 'cores': 1, + 'key_pairs': 1, + 'server_groups': 1, + 'server_group_members': 1 + } diff --git a/novaclient/tests/unit/v2/fakes.py b/novaclient/tests/unit/v2/fakes.py index 9b059a38a..11789ec19 100644 --- a/novaclient/tests/unit/v2/fakes.py +++ b/novaclient/tests/unit/v2/fakes.py @@ -331,6 +331,14 @@ class FakeSessionClient(base_client.SessionClient): # def get_limits(self, **kw): + absolute = { + "maxTotalRAMSize": 51200, + "maxServerMeta": 5, + "maxImageMeta": 5 + } + # 2.57 removes injected_file* entries from the response. + if self.api_version < api_versions.APIVersion('2.57'): + absolute.update({"maxPersonality": 5, "maxPersonalitySize": 10240}) return (200, {}, {"limits": { "rate": [ { @@ -374,13 +382,7 @@ class FakeSessionClient(base_client.SessionClient): ] } ], - "absolute": { - "maxTotalRAMSize": 51200, - "maxServerMeta": 5, - "maxImageMeta": 5, - "maxPersonality": 5, - "maxPersonalitySize": 10240 - }, + "absolute": absolute, }}) # @@ -1297,6 +1299,19 @@ class FakeSessionClient(base_client.SessionClient): # def get_os_quota_class_sets_test(self, **kw): + # 2.57 removes injected_file* entries from the response. + if self.api_version >= api_versions.APIVersion('2.57'): + return (200, FAKE_RESPONSE_HEADERS, { + 'quota_class_set': { + 'id': 'test', + 'metadata_items': 1, + 'ram': 1, + 'instances': 1, + 'cores': 1, + 'key_pairs': 1, + 'server_groups': 1, + 'server_group_members': 1}}) + if self.api_version >= api_versions.APIVersion('2.50'): return (200, FAKE_RESPONSE_HEADERS, { 'quota_class_set': { @@ -1329,6 +1344,18 @@ class FakeSessionClient(base_client.SessionClient): def put_os_quota_class_sets_test(self, body, **kw): assert list(body) == ['quota_class_set'] + # 2.57 removes injected_file* entries from the response. + if self.api_version >= api_versions.APIVersion('2.57'): + return (200, {}, { + 'quota_class_set': { + 'metadata_items': 1, + 'ram': 1, + 'instances': 1, + 'cores': 1, + 'key_pairs': 1, + 'server_groups': 1, + 'server_group_members': 1}}) + if self.api_version >= api_versions.APIVersion('2.50'): return (200, {}, { 'quota_class_set': { diff --git a/novaclient/tests/unit/v2/test_limits.py b/novaclient/tests/unit/v2/test_limits.py index a0b8bcf2a..1abcd1a5d 100644 --- a/novaclient/tests/unit/v2/test_limits.py +++ b/novaclient/tests/unit/v2/test_limits.py @@ -11,6 +11,7 @@ # License for the specific language governing permissions and limitations # under the License. +from novaclient import api_versions from novaclient.tests.unit.fixture_data import client from novaclient.tests.unit.fixture_data import limits as data from novaclient.tests.unit import utils @@ -22,6 +23,8 @@ class LimitsTest(utils.FixturedTestCase): client_fixture_class = client.V1 data_fixture_class = data.Fixture + supports_image_meta = True # 2.39 deprecates maxImageMeta + supports_personality = True # 2.57 deprecates maxPersonality* def test_get_limits(self): obj = self.cs.limits.get() @@ -39,13 +42,16 @@ class LimitsTest(utils.FixturedTestCase): obj = self.cs.limits.get(reserved=True) self.assert_request_id(obj, fakes.FAKE_REQUEST_ID_LIST) - expected = ( + expected = [ limits.AbsoluteLimit("maxTotalRAMSize", 51200), - limits.AbsoluteLimit("maxServerMeta", 5), - limits.AbsoluteLimit("maxImageMeta", 5), - limits.AbsoluteLimit("maxPersonality", 5), - limits.AbsoluteLimit("maxPersonalitySize", 10240), - ) + limits.AbsoluteLimit("maxServerMeta", 5) + ] + if self.supports_image_meta: + expected.append(limits.AbsoluteLimit("maxImageMeta", 5)) + if self.supports_personality: + expected.extend([ + limits.AbsoluteLimit("maxPersonality", 5), + limits.AbsoluteLimit("maxPersonalitySize", 10240)]) self.assert_called('GET', '/limits?reserved=1') abs_limits = list(obj.absolute) @@ -75,16 +81,29 @@ class LimitsTest(utils.FixturedTestCase): for limit in rate_limits: self.assertIn(limit, expected) - expected = ( + expected = [ limits.AbsoluteLimit("maxTotalRAMSize", 51200), - limits.AbsoluteLimit("maxServerMeta", 5), - limits.AbsoluteLimit("maxImageMeta", 5), - limits.AbsoluteLimit("maxPersonality", 5), - limits.AbsoluteLimit("maxPersonalitySize", 10240), - ) + limits.AbsoluteLimit("maxServerMeta", 5) + ] + if self.supports_image_meta: + expected.append(limits.AbsoluteLimit("maxImageMeta", 5)) + if self.supports_personality: + expected.extend([ + limits.AbsoluteLimit("maxPersonality", 5), + limits.AbsoluteLimit("maxPersonalitySize", 10240)]) abs_limits = list(obj.absolute) self.assertEqual(len(abs_limits), len(expected)) for limit in abs_limits: self.assertIn(limit, expected) + + +class LimitsTest2_57(LimitsTest): + data_fixture_class = data.Fixture2_57 + supports_image_meta = False + supports_personality = False + + def setUp(self): + super(LimitsTest2_57, self).setUp() + self.cs.api_version = api_versions.APIVersion('2.57') diff --git a/novaclient/tests/unit/v2/test_quota_classes.py b/novaclient/tests/unit/v2/test_quota_classes.py index e9d9ad75a..3becb6323 100644 --- a/novaclient/tests/unit/v2/test_quota_classes.py +++ b/novaclient/tests/unit/v2/test_quota_classes.py @@ -49,17 +49,20 @@ class QuotaClassSetsTest(utils.TestCase): class QuotaClassSetsTest2_50(QuotaClassSetsTest): """Tests the quota classes API binding using the 2.50 microversion.""" + api_version = '2.50' + invalid_resources = ['floating_ips', 'fixed_ips', 'networks', + 'security_groups', 'security_group_rules'] + def setUp(self): super(QuotaClassSetsTest2_50, self).setUp() - self.cs = fakes.FakeClient(api_versions.APIVersion("2.50")) + self.cs = fakes.FakeClient(api_versions.APIVersion(self.api_version)) def test_class_quotas_get(self): """Tests that network-related resources aren't in a 2.50 response and server group related resources are in the response. """ q = super(QuotaClassSetsTest2_50, self).test_class_quotas_get() - for invalid_resource in ('floating_ips', 'fixed_ips', 'networks', - 'security_groups', 'security_group_rules'): + for invalid_resource in self.invalid_resources: self.assertFalse(hasattr(q, invalid_resource), '%s should not be in %s' % (invalid_resource, q)) # Also make sure server_groups and server_group_members are in the @@ -73,8 +76,7 @@ class QuotaClassSetsTest2_50(QuotaClassSetsTest): and server group related resources are in the response. """ q = super(QuotaClassSetsTest2_50, self).test_update_quota() - for invalid_resource in ('floating_ips', 'fixed_ips', 'networks', - 'security_groups', 'security_group_rules'): + for invalid_resource in self.invalid_resources: self.assertFalse(hasattr(q, invalid_resource), '%s should not be in %s' % (invalid_resource, q)) # Also make sure server_groups and server_group_members are in the @@ -95,3 +97,27 @@ class QuotaClassSetsTest2_50(QuotaClassSetsTest): self.assertRaises(TypeError, q.update, security_groups=1) self.assertRaises(TypeError, q.update, security_group_rules=1) self.assertRaises(TypeError, q.update, networks=1) + return q + + +class QuotaClassSetsTest2_57(QuotaClassSetsTest2_50): + """Tests the quota classes API binding using the 2.57 microversion.""" + api_version = '2.57' + + def setUp(self): + super(QuotaClassSetsTest2_57, self).setUp() + self.invalid_resources.extend(['injected_files', + 'injected_file_content_bytes', + 'injected_file_path_bytes']) + + def test_update_quota_invalid_resources(self): + """Tests trying to update quota class values for invalid resources. + + This will fail with TypeError because the file-related resource + kwargs aren't defined. + """ + q = super( + QuotaClassSetsTest2_57, self).test_update_quota_invalid_resources() + self.assertRaises(TypeError, q.update, injected_files=1) + self.assertRaises(TypeError, q.update, injected_file_content_bytes=1) + self.assertRaises(TypeError, q.update, injected_file_path_bytes=1) diff --git a/novaclient/tests/unit/v2/test_quotas.py b/novaclient/tests/unit/v2/test_quotas.py index 0ed766a03..67a0bc3df 100644 --- a/novaclient/tests/unit/v2/test_quotas.py +++ b/novaclient/tests/unit/v2/test_quotas.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +from novaclient import api_versions from novaclient.tests.unit.fixture_data import client from novaclient.tests.unit.fixture_data import quotas as data from novaclient.tests.unit import utils @@ -29,6 +30,7 @@ class QuotaSetsTest(utils.FixturedTestCase): q = self.cs.quotas.get(tenant_id) self.assert_request_id(q, fakes.FAKE_REQUEST_ID_LIST) self.assert_called('GET', '/os-quota-sets/%s' % tenant_id) + return q def test_user_quotas_get(self): tenant_id = 'test' @@ -65,6 +67,7 @@ class QuotaSetsTest(utils.FixturedTestCase): self.assert_called( 'PUT', '/os-quota-sets/97f4c221bff44578b0300df4ef119353', {'quota_set': {'force': True, 'cores': 2}}) + return q def test_quotas_delete(self): tenant_id = 'test' @@ -79,3 +82,40 @@ class QuotaSetsTest(utils.FixturedTestCase): self.assert_request_id(ret, fakes.FAKE_REQUEST_ID_LIST) url = '/os-quota-sets/%s?user_id=%s' % (tenant_id, user_id) self.assert_called('DELETE', url) + + +class QuotaSetsTest2_57(QuotaSetsTest): + """Tests the quotas API binding using the 2.57 microversion.""" + data_fixture_class = data.V2_57 + invalid_resources = ['floating_ips', 'fixed_ips', 'networks', + 'security_groups', 'security_group_rules', + 'injected_files', 'injected_file_content_bytes', + 'injected_file_path_bytes'] + + def setUp(self): + super(QuotaSetsTest2_57, self).setUp() + self.cs.api_version = api_versions.APIVersion('2.57') + + def test_tenant_quotas_get(self): + q = super(QuotaSetsTest2_57, self).test_tenant_quotas_get() + for invalid_resource in self.invalid_resources: + self.assertFalse(hasattr(q, invalid_resource), + '%s should not be in %s' % (invalid_resource, q)) + + def test_force_update_quota(self): + q = super(QuotaSetsTest2_57, self).test_force_update_quota() + for invalid_resource in self.invalid_resources: + self.assertFalse(hasattr(q, invalid_resource), + '%s should not be in %s' % (invalid_resource, q)) + + def test_update_quota_invalid_resources(self): + """Tests trying to update quota values for invalid resources.""" + q = self.cs.quotas.get('test') + self.assertRaises(TypeError, q.update, floating_ips=1) + self.assertRaises(TypeError, q.update, fixed_ips=1) + self.assertRaises(TypeError, q.update, security_groups=1) + self.assertRaises(TypeError, q.update, security_group_rules=1) + self.assertRaises(TypeError, q.update, networks=1) + self.assertRaises(TypeError, q.update, injected_files=1) + self.assertRaises(TypeError, q.update, injected_file_content_bytes=1) + self.assertRaises(TypeError, q.update, injected_file_path_bytes=1) diff --git a/novaclient/tests/unit/v2/test_servers.py b/novaclient/tests/unit/v2/test_servers.py index b09bf6cdc..07ad1497e 100644 --- a/novaclient/tests/unit/v2/test_servers.py +++ b/novaclient/tests/unit/v2/test_servers.py @@ -34,6 +34,7 @@ class ServersTest(utils.FixturedTestCase): client_fixture_class = client.V1 data_fixture_class = data.V1 api_version = None + supports_files = True def setUp(self): super(ServersTest, self).setUp() @@ -126,6 +127,12 @@ class ServersTest(utils.FixturedTestCase): self.assertEqual(s1._info, s2._info) def test_create_server(self): + kwargs = {} + if self.supports_files: + kwargs['files'] = { + '/etc/passwd': 'some data', # a file + '/tmp/foo.txt': six.StringIO('data'), # a stream + } s = self.cs.servers.create( name="My server", image=1, @@ -133,11 +140,8 @@ class ServersTest(utils.FixturedTestCase): meta={'foo': 'bar'}, userdata="hello moto", key_name="fakekey", - files={ - '/etc/passwd': 'some data', # a file - '/tmp/foo.txt': six.StringIO('data'), # a stream - }, - nics=self._get_server_create_default_nics() + nics=self._get_server_create_default_nics(), + **kwargs ) self.assert_request_id(s, fakes.FAKE_REQUEST_ID_LIST) self.assert_called('POST', '/servers') @@ -253,23 +257,32 @@ class ServersTest(utils.FixturedTestCase): self.assertIsInstance(s, servers.Server) def test_create_server_userdata_file_object(self): + kwargs = {} + if self.supports_files: + kwargs['files'] = { + '/etc/passwd': 'some data', # a file + '/tmp/foo.txt': six.StringIO('data'), # a stream + } s = self.cs.servers.create( name="My server", image=1, flavor=1, meta={'foo': 'bar'}, userdata=six.StringIO('hello moto'), - files={ - '/etc/passwd': 'some data', # a file - '/tmp/foo.txt': six.StringIO('data'), # a stream - }, nics=self._get_server_create_default_nics(), + **kwargs ) self.assert_request_id(s, fakes.FAKE_REQUEST_ID_LIST) self.assert_called('POST', '/servers') self.assertIsInstance(s, servers.Server) def test_create_server_userdata_unicode(self): + kwargs = {} + if self.supports_files: + kwargs['files'] = { + '/etc/passwd': 'some data', # a file + '/tmp/foo.txt': six.StringIO('data'), # a stream + } s = self.cs.servers.create( name="My server", image=1, @@ -277,17 +290,20 @@ class ServersTest(utils.FixturedTestCase): meta={'foo': 'bar'}, userdata=six.u('こんにちは'), key_name="fakekey", - files={ - '/etc/passwd': 'some data', # a file - '/tmp/foo.txt': six.StringIO('data'), # a stream - }, nics=self._get_server_create_default_nics(), + **kwargs ) self.assert_request_id(s, fakes.FAKE_REQUEST_ID_LIST) self.assert_called('POST', '/servers') self.assertIsInstance(s, servers.Server) def test_create_server_userdata_utf8(self): + kwargs = {} + if self.supports_files: + kwargs['files'] = { + '/etc/passwd': 'some data', # a file + '/tmp/foo.txt': six.StringIO('data'), # a stream + } s = self.cs.servers.create( name="My server", image=1, @@ -295,11 +311,8 @@ class ServersTest(utils.FixturedTestCase): meta={'foo': 'bar'}, userdata='こんにちは', key_name="fakekey", - files={ - '/etc/passwd': 'some data', # a file - '/tmp/foo.txt': six.StringIO('data'), # a stream - }, nics=self._get_server_create_default_nics(), + **kwargs ) self.assert_request_id(s, fakes.FAKE_REQUEST_ID_LIST) self.assert_called('POST', '/servers') @@ -323,6 +336,12 @@ class ServersTest(utils.FixturedTestCase): self.assertEqual(test_password, body['server']['adminPass']) def test_create_server_userdata_bin(self): + kwargs = {} + if self.supports_files: + kwargs['files'] = { + '/etc/passwd': 'some data', # a file + '/tmp/foo.txt': six.StringIO('data'), # a stream + } with tempfile.TemporaryFile(mode='wb+') as bin_file: original_data = os.urandom(1024) bin_file.write(original_data) @@ -335,11 +354,8 @@ class ServersTest(utils.FixturedTestCase): meta={'foo': 'bar'}, userdata=bin_file, key_name="fakekey", - files={ - '/etc/passwd': 'some data', # a file - '/tmp/foo.txt': six.StringIO('data'), # a stream - }, nics=self._get_server_create_default_nics(), + **kwargs ) self.assert_request_id(s, fakes.FAKE_REQUEST_ID_LIST) self.assert_called('POST', '/servers') @@ -1500,3 +1516,29 @@ class ServersV256Test(ServersV254Test): ex = self.assertRaises(TypeError, s.migrate, host='target-host') self.assertIn('host', six.text_type(ex)) + + +class ServersV257Test(ServersV256Test): + """Tests the servers python API bindings with microversion 2.57 where + personality files are deprecated. + """ + api_version = "2.57" + supports_files = False + + def test_create_server_with_files_fails(self): + ex = self.assertRaises( + exceptions.UnsupportedAttribute, self.cs.servers.create, + name="My server", image=1, flavor=1, + files={ + '/etc/passwd': 'some data', # a file + '/tmp/foo.txt': six.StringIO('data'), # a stream + }, nics='auto') + self.assertIn('files', six.text_type(ex)) + + def test_rebuild_server_name_meta_files(self): + files = {'/etc/passwd': 'some data'} + s = self.cs.servers.get(1234) + ex = self.assertRaises( + exceptions.UnsupportedAttribute, s.rebuild, image=1, name='new', + meta={'foo': 'bar'}, files=files) + self.assertIn('files', six.text_type(ex)) diff --git a/novaclient/tests/unit/v2/test_shell.py b/novaclient/tests/unit/v2/test_shell.py index 5163959a5..fb21eb5e7 100644 --- a/novaclient/tests/unit/v2/test_shell.py +++ b/novaclient/tests/unit/v2/test_shell.py @@ -36,6 +36,7 @@ from novaclient import exceptions import novaclient.shell from novaclient.tests.unit import utils from novaclient.tests.unit.v2 import fakes +from novaclient.v2 import servers import novaclient.v2.shell FAKE_UUID_1 = fakes.FAKE_IMAGE_UUID_1 @@ -971,6 +972,16 @@ class ShellTest(utils.TestCase): ' --file /foo=%s' % (FAKE_UUID_1, invalid_file)) self.assertRaises(exceptions.CommandError, self.run_command, cmd) + def test_boot_files_2_57(self): + """Tests that trying to run the boot command with the --file option + after microversion 2.56 fails. + """ + testfile = os.path.join(os.path.dirname(__file__), 'testfile.txt') + cmd = ('boot some-server --flavor 1 --image %s' + ' --file /tmp/foo=%s') + self.assertRaises(SystemExit, self.run_command, + cmd % (FAKE_UUID_1, testfile), api_version='2.57') + def test_boot_max_min_count(self): self.run_command('boot --image %s --flavor 1 --min-count 1' ' --max-count 3 server' % FAKE_UUID_1) @@ -1570,6 +1581,62 @@ class ShellTest(utils.TestCase): expected = "'['foo']' is not in the format of 'key=value'" self.assertEqual(expected, result.args[0]) + def test_rebuild_user_data_2_56(self): + """Tests that trying to run the rebuild command with the --user-data* + options before microversion 2.57 fails. + """ + cmd = 'rebuild sample-server %s --user-data test' % FAKE_UUID_1 + self.assertRaises(SystemExit, self.run_command, cmd, + api_version='2.56') + cmd = 'rebuild sample-server %s --user-data-unset' % FAKE_UUID_1 + self.assertRaises(SystemExit, self.run_command, cmd, + api_version='2.56') + + def test_rebuild_files_2_57(self): + """Tests that trying to run the rebuild command with the --file option + after microversion 2.56 fails. + """ + testfile = os.path.join(os.path.dirname(__file__), 'testfile.txt') + cmd = 'rebuild sample-server %s --file /tmp/foo=%s' + self.assertRaises(SystemExit, self.run_command, + cmd % (FAKE_UUID_1, testfile), api_version='2.57') + + def test_rebuild_change_user_data(self): + self.run_command('rebuild sample-server %s --user-data test' % + FAKE_UUID_1, api_version='2.57') + user_data = servers.ServerManager.transform_userdata('test') + self.assert_called('GET', '/servers?name=sample-server', pos=0) + self.assert_called('GET', '/servers/1234', pos=1) + self.assert_called('GET', '/v2/images/%s' % FAKE_UUID_1, pos=2) + self.assert_called('POST', '/servers/1234/action', + {'rebuild': {'imageRef': FAKE_UUID_1, + 'user_data': user_data, + 'description': None}}, pos=3) + self.assert_called('GET', '/v2/images/%s' % FAKE_UUID_2, pos=4) + + def test_rebuild_unset_user_data(self): + self.run_command('rebuild sample-server %s --user-data-unset' % + FAKE_UUID_1, api_version='2.57') + self.assert_called('GET', '/servers?name=sample-server', pos=0) + self.assert_called('GET', '/servers/1234', pos=1) + self.assert_called('GET', '/v2/images/%s' % FAKE_UUID_1, pos=2) + self.assert_called('POST', '/servers/1234/action', + {'rebuild': {'imageRef': FAKE_UUID_1, + 'user_data': None, + 'description': None}}, pos=3) + self.assert_called('GET', '/v2/images/%s' % FAKE_UUID_2, pos=4) + + def test_rebuild_user_data_and_unset_user_data(self): + """Tests that trying to set --user-data and --unset-user-data in the + same rebuild call fails. + """ + cmd = ('rebuild sample-server %s --user-data x --user-data-unset' % + FAKE_UUID_1) + ex = self.assertRaises(exceptions.CommandError, self.run_command, cmd, + api_version='2.57') + self.assertIn("Cannot specify '--user-data-unset' with " + "'--user-data'.", six.text_type(ex)) + def test_start(self): self.run_command('start sample-server') self.assert_called('POST', '/servers/1234/action', {'os-start': None}) @@ -2643,6 +2710,17 @@ class ShellTest(utils.TestCase): 'PUT', '/os-quota-sets/97f4c221bff44578b0300df4ef119353', {'quota_set': {'fixed_ips': 5}}) + def test_quota_update_injected_file_2_57(self): + """Tests that trying to update injected_file* quota with microversion + 2.57 fails. + """ + for quota in ('--injected-files', '--injected-file-content-bytes', + '--injected-file-path-bytes'): + cmd = ('quota-update 97f4c221bff44578b0300df4ef119353 %s=5' % + quota) + self.assertRaises(SystemExit, self.run_command, cmd, + api_version='2.57') + def test_quota_delete(self): self.run_command('quota-delete --tenant ' '97f4c221bff44578b0300df4ef119353') @@ -2680,6 +2758,16 @@ class ShellTest(utils.TestCase): 'PUT', '/os-quota-class-sets/97f4c221bff44578b0300df4ef119353', body) + def test_quota_class_update_injected_file_2_57(self): + """Tests that trying to update injected_file* quota with microversion + 2.57 fails. + """ + for quota in ('--injected-files', '--injected-file-content-bytes', + '--injected-file-path-bytes'): + cmd = 'quota-class-update default %s=5' % quota + self.assertRaises(SystemExit, self.run_command, cmd, + api_version='2.57') + def test_backup(self): out, err = self.run_command('backup sample-server back1 daily 1') # With microversion < 2.45 there is no output from this command. @@ -2712,8 +2800,9 @@ class ShellTest(utils.TestCase): 'rotation': '1'}}) def test_limits(self): - self.run_command('limits') + out = self.run_command('limits')[0] self.assert_called('GET', '/limits') + self.assertIn('Personality', out) self.run_command('limits --reserved') self.assert_called('GET', '/limits?reserved=1') @@ -2725,6 +2814,14 @@ class ShellTest(utils.TestCase): self.assertIn('Verb', stdout) self.assertIn('Name', stdout) + def test_limits_2_57(self): + """Tests the limits command at microversion 2.57 where personality + size limits should not be shown. + """ + out = self.run_command('limits', api_version='2.57')[0] + self.assert_called('GET', '/limits') + self.assertNotIn('Personality', out) + def test_evacuate(self): self.run_command('evacuate sample-server new_host') self.assert_called('POST', '/servers/1234/action', @@ -3128,6 +3225,7 @@ class ShellTest(utils.TestCase): 51, # There are no version-wrapped shell method changes for this. 52, # There are no version-wrapped shell method changes for this. 54, # There are no version-wrapped shell method changes for this. + 57, # There are no version-wrapped shell method changes for this. ]) versions_supported = set(range(0, novaclient.API_MAX_VERSION.ver_minor + 1)) diff --git a/novaclient/v2/quota_classes.py b/novaclient/v2/quota_classes.py index eae5bfdec..917cc9c43 100644 --- a/novaclient/v2/quota_classes.py +++ b/novaclient/v2/quota_classes.py @@ -50,7 +50,7 @@ class QuotaClassSetManager(base.Manager): # NOTE(mriedem): 2.50 does strict validation of the resources you can # specify since the network-related resources are blocked in 2.50. - @api_versions.wraps("2.50") + @api_versions.wraps("2.50", "2.56") def update(self, class_name, instances=None, cores=None, ram=None, metadata_items=None, injected_files=None, injected_file_content_bytes=None, injected_file_path_bytes=None, @@ -81,3 +81,30 @@ class QuotaClassSetManager(base.Manager): body = {'quota_class_set': resources} return self._update('/os-quota-class-sets/%s' % class_name, body, 'quota_class_set') + + # NOTE(mriedem): 2.57 deprecates the usage of injected_files, + # injected_file_content_bytes and injected_file_path_bytes so those + # kwargs are removed. + @api_versions.wraps("2.57") + def update(self, class_name, instances=None, cores=None, ram=None, + metadata_items=None, key_pairs=None, server_groups=None, + server_group_members=None): + resources = {} + if instances is not None: + resources['instances'] = instances + if cores is not None: + resources['cores'] = cores + if ram is not None: + resources['ram'] = ram + if metadata_items is not None: + resources['metadata_items'] = metadata_items + if key_pairs is not None: + resources['key_pairs'] = key_pairs + if server_groups is not None: + resources['server_groups'] = server_groups + if server_group_members is not None: + resources['server_group_members'] = server_group_members + + body = {'quota_class_set': resources} + return self._update('/os-quota-class-sets/%s' % class_name, body, + 'quota_class_set') diff --git a/novaclient/v2/quotas.py b/novaclient/v2/quotas.py index 0e421169b..82249f25e 100644 --- a/novaclient/v2/quotas.py +++ b/novaclient/v2/quotas.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +from novaclient import api_versions from novaclient import base @@ -38,6 +39,10 @@ class QuotaSetManager(base.Manager): return self._get(url % params, "quota_set") + # NOTE(mriedem): Before 2.57 the resources you could update was just a + # kwargs dict and not validated on the client-side, only on the API server + # side. + @api_versions.wraps("2.0", "2.56") def update(self, tenant_id, **kwargs): user_id = kwargs.pop('user_id', None) @@ -53,6 +58,40 @@ class QuotaSetManager(base.Manager): url = '/os-quota-sets/%s' % tenant_id return self._update(url, body, 'quota_set') + # NOTE(mriedem): 2.57 does strict validation of the resources you can + # specify. 2.36 blocks network-related resources and 2.57 blocks + # injected files related quotas. + @api_versions.wraps("2.57") + def update(self, tenant_id, user_id=None, force=False, + instances=None, cores=None, ram=None, + metadata_items=None, key_pairs=None, server_groups=None, + server_group_members=None): + + resources = {} + if force: + resources['force'] = force + if instances is not None: + resources['instances'] = instances + if cores is not None: + resources['cores'] = cores + if ram is not None: + resources['ram'] = ram + if metadata_items is not None: + resources['metadata_items'] = metadata_items + if key_pairs is not None: + resources['key_pairs'] = key_pairs + if server_groups is not None: + resources['server_groups'] = server_groups + if server_group_members is not None: + resources['server_group_members'] = server_group_members + body = {'quota_set': resources} + + if user_id: + url = '/os-quota-sets/%s?user_id=%s' % (tenant_id, user_id) + else: + url = '/os-quota-sets/%s' % tenant_id + return self._update(url, body, 'quota_set') + def defaults(self, tenant_id): return self._get('/os-quota-sets/%s/defaults' % tenant_id, 'quota_set') diff --git a/novaclient/v2/servers.py b/novaclient/v2/servers.py index e305253ed..79ab0cdce 100644 --- a/novaclient/v2/servers.py +++ b/novaclient/v2/servers.py @@ -621,6 +621,27 @@ class SecurityGroup(base.Resource): class ServerManager(base.BootingManagerWithFind): resource_class = Server + @staticmethod + def transform_userdata(userdata): + if hasattr(userdata, 'read'): + userdata = userdata.read() + + # NOTE(melwitt): Text file data is converted to bytes prior to + # base64 encoding. The utf-8 encoding will fail for binary files. + if six.PY3: + try: + userdata = userdata.encode("utf-8") + except AttributeError: + # In python 3, 'bytes' object has no attribute 'encode' + pass + else: + try: + userdata = encodeutils.safe_encode(userdata) + except UnicodeDecodeError: + pass + + return base64.b64encode(userdata).decode('utf-8') + def _boot(self, response_key, name, image, flavor, meta=None, files=None, userdata=None, reservation_id=False, return_raw=False, min_count=None, @@ -639,25 +660,7 @@ class ServerManager(base.BootingManagerWithFind): "flavorRef": str(base.getid(flavor)), }} if userdata: - if hasattr(userdata, 'read'): - userdata = userdata.read() - - # NOTE(melwitt): Text file data is converted to bytes prior to - # base64 encoding. The utf-8 encoding will fail for binary files. - if six.PY3: - try: - userdata = userdata.encode("utf-8") - except AttributeError: - # In python 3, 'bytes' object has no attribute 'encode' - pass - else: - try: - userdata = encodeutils.safe_encode(userdata) - except UnicodeDecodeError: - pass - - userdata_b64 = base64.b64encode(userdata).decode('utf-8') - body["server"]["user_data"] = userdata_b64 + body["server"]["user_data"] = self.transform_userdata(userdata) if meta: body["server"]["metadata"] = meta if reservation_id: @@ -1204,6 +1207,7 @@ class ServerManager(base.BootingManagerWithFind): are the file contents (either as a string or as a file-like object). A maximum of five entries is allowed, and each file must be 10k or less. + (deprecated starting with microversion 2.57) :param reservation_id: return a reservation_id for the set of servers being requested, boolean. :param min_count: (optional extension) The minimum number of @@ -1284,6 +1288,10 @@ class ServerManager(base.BootingManagerWithFind): if "tags" in kwargs and self.api_version < boot_tags_microversion: raise exceptions.UnsupportedAttribute("tags", "2.52") + personality_files_deprecation = api_versions.APIVersion('2.57') + if files and self.api_version >= personality_files_deprecation: + raise exceptions.UnsupportedAttribute('files', '2.0', '2.56') + boot_kwargs = dict( meta=meta, files=files, userdata=userdata, reservation_id=reservation_id, min_count=min_count, @@ -1397,11 +1405,17 @@ class ServerManager(base.BootingManagerWithFind): are the file contents (either as a string or as a file-like object). A maximum of five entries is allowed, and each file must be 10k or less. + (deprecated starting with microversion 2.57) :param description: optional description of the server (allowed since microversion 2.19) :param key_name: optional key pair name for rebuild operation; passing None will unset the key for the server instance (starting from microversion 2.54) + :param userdata: optional user data to pass to be exposed by the + metadata server; this can be a file type object as + well or a string. If None is specified, the existing + user_data is unset. + (starting from microversion 2.57) :returns: :class:`Server` """ descr_microversion = api_versions.APIVersion("2.19") @@ -1414,6 +1428,14 @@ class ServerManager(base.BootingManagerWithFind): self.api_version < api_versions.APIVersion('2.54')): raise exceptions.UnsupportedAttribute('key_name', '2.54') + # Microversion 2.57 deprecates personality files and adds support + # for user_data. + files_and_userdata = api_versions.APIVersion('2.57') + if files and self.api_version >= files_and_userdata: + raise exceptions.UnsupportedAttribute('files', '2.0', '2.56') + if 'userdata' in kwargs and self.api_version < files_and_userdata: + raise exceptions.UnsupportedAttribute('userdata', '2.57') + body = {'imageRef': base.getid(image)} if password is not None: body['adminPass'] = password @@ -1443,6 +1465,12 @@ class ServerManager(base.BootingManagerWithFind): 'path': filepath, 'contents': cont, }) + if 'userdata' in kwargs: + # If userdata is specified but None, it means unset the existing + # user_data on the instance. + userdata = kwargs['userdata'] + body['user_data'] = (userdata if userdata is None else + self.transform_userdata(userdata)) resp, body = self._action_return_resp_and_body('rebuild', server, body, **kwargs) diff --git a/novaclient/v2/shell.py b/novaclient/v2/shell.py index 3d8edb633..9901d12bb 100644 --- a/novaclient/v2/shell.py +++ b/novaclient/v2/shell.py @@ -391,19 +391,22 @@ def _boot(cs, args): meta = _meta_parsing(args.meta) - files = {} - for f in args.files: - try: - dst, src = f.split('=', 1) - files[dst] = open(src) - except IOError as e: - raise exceptions.CommandError(_("Can't open '%(src)s': %(exc)s") % - {'src': src, 'exc': e}) - except ValueError: - raise exceptions.CommandError(_("Invalid file argument '%s'. " - "File arguments must be of the " - "form '--file " - "'") % f) + include_files = cs.api_version < api_versions.APIVersion('2.57') + if include_files: + files = {} + for f in args.files: + try: + dst, src = f.split('=', 1) + files[dst] = open(src) + except IOError as e: + raise exceptions.CommandError( + _("Can't open '%(src)s': %(exc)s") % + {'src': src, 'exc': e}) + except ValueError: + raise exceptions.CommandError( + _("Invalid file argument '%s'. " + "File arguments must be of the " + "form '--file '") % f) # use the os-keypair extension key_name = None @@ -481,7 +484,6 @@ def _boot(cs, args): boot_kwargs = dict( meta=meta, - files=files, key_name=key_name, min_count=min_count, max_count=max_count, @@ -504,6 +506,9 @@ def _boot(cs, args): if 'tags' in args and args.tags: boot_kwargs["tags"] = args.tags.split(',') + if include_files: + boot_kwargs['files'] = files + return boot_args, boot_kwargs @@ -563,7 +568,8 @@ def _boot(cs, args): "on the new server. More files can be injected using multiple " "'--file' options. Limited by the 'injected_files' quota value. " "The default value is 5. You can get the current quota value by " - "'Personality' limit from 'nova limits' command.")) + "'Personality' limit from 'nova limits' command."), + start_version='2.0', end_version='2.56') @utils.arg( '--key-name', default=os.environ.get('NOVACLIENT_DEFAULT_KEY_NAME'), @@ -1770,7 +1776,8 @@ def do_reboot(cs, args): "on the new server. More files can be injected using multiple " "'--file' options. You may store up to 5 files by default. " "The maximum number of files is specified by the 'Personality' " - "limit reported by the 'nova limits' command.")) + "limit reported by the 'nova limits' command."), + start_version='2.0', end_version='2.56') @utils.arg( '--key-name', metavar='', @@ -1785,6 +1792,19 @@ def do_reboot(cs, args): help=_("Unset keypair in the server. " "Cannot be specified with the '--key-name' option."), start_version='2.54') +@utils.arg( + '--user-data', + default=None, + metavar='', + help=_("User data file to pass to be exposed by the metadata server."), + start_version='2.57') +@utils.arg( + '--user-data-unset', + action='store_true', + default=False, + help=_("Unset user_data in the server. Cannot be specified with the " + "'--user-data' option."), + start_version='2.57') def do_rebuild(cs, args): """Shutdown, re-image, and re-boot a server.""" server = _find_server(cs, args.server) @@ -1803,21 +1823,34 @@ def do_rebuild(cs, args): meta = _meta_parsing(args.meta) kwargs['meta'] = meta - files = {} - for f in args.files: - try: - dst, src = f.split('=', 1) - with open(src, 'r') as s: - files[dst] = s.read() - except IOError as e: - raise exceptions.CommandError(_("Can't open '%(src)s': %(exc)s") % - {'src': src, 'exc': e}) - except ValueError: - raise exceptions.CommandError(_("Invalid file argument '%s'. " - "File arguments must be of the " - "form '--file " - "'") % f) - kwargs['files'] = files + # 2.57 deprecates the --file option and adds the --user-data and + # --user-data-unset options. + if cs.api_version < api_versions.APIVersion('2.57'): + files = {} + for f in args.files: + try: + dst, src = f.split('=', 1) + with open(src, 'r') as s: + files[dst] = s.read() + except IOError as e: + raise exceptions.CommandError( + _("Can't open '%(src)s': %(exc)s") % + {'src': src, 'exc': e}) + except ValueError: + raise exceptions.CommandError( + _("Invalid file argument '%s'. " + "File arguments must be of the " + "form '--file '") % f) + kwargs['files'] = files + else: + if args.user_data_unset: + kwargs['userdata'] = None + if args.user_data: + raise exceptions.CommandError( + _("Cannot specify '--user-data-unset' with " + "'--user-data'.")) + elif args.user_data: + kwargs['userdata'] = args.user_data if cs.api_version >= api_versions.APIVersion('2.54'): if args.key_unset: @@ -3739,6 +3772,10 @@ def do_ssh(cs, args): # return floating_ips, fixed_ips, security_groups or security_group_members # as those are deprecated as networking service proxies and/or because # nova-network is deprecated. Similar to the 2.36 microversion. +# NOTE(mriedem): In the 2.57 microversion, the os-quota-sets and +# os-quota-class-sets APIs will no longer return injected_files, +# injected_file_content_bytes or injected_file_content_bytes since personality +# files (file injection) is deprecated starting with v2.57. _quota_resources = ['instances', 'cores', 'ram', 'floating_ips', 'fixed_ips', 'metadata_items', 'injected_files', 'injected_file_content_bytes', @@ -3942,6 +3979,7 @@ def do_quota_update(cs, args): # 2.36 does not support updating quota for floating IPs, fixed IPs, security # groups or security group rules. +# 2.57 does not support updating injected_file* quotas. @api_versions.wraps("2.36") @utils.arg( 'tenant', @@ -3978,19 +4016,22 @@ def do_quota_update(cs, args): metavar='', type=int, default=None, - help=_('New value for the "injected-files" quota.')) + help=_('New value for the "injected-files" quota.'), + start_version='2.36', end_version='2.56') @utils.arg( '--injected-file-content-bytes', metavar='', type=int, default=None, - help=_('New value for the "injected-file-content-bytes" quota.')) + help=_('New value for the "injected-file-content-bytes" quota.'), + start_version='2.36', end_version='2.56') @utils.arg( '--injected-file-path-bytes', metavar='', type=int, default=None, - help=_('New value for the "injected-file-path-bytes" quota.')) + help=_('New value for the "injected-file-path-bytes" quota.'), + start_version='2.36', end_version='2.56') @utils.arg( '--key-pairs', metavar='', @@ -4147,6 +4188,7 @@ def do_quota_class_update(cs, args): # 2.50 does not support updating quota class values for floating IPs, # fixed IPs, security groups or security group rules. +# 2.57 does not support updating injected_file* quotas. @api_versions.wraps("2.50") @utils.arg( 'class_name', @@ -4178,19 +4220,22 @@ def do_quota_class_update(cs, args): metavar='', type=int, default=None, - help=_('New value for the "injected-files" quota.')) + help=_('New value for the "injected-files" quota.'), + start_version='2.50', end_version='2.56') @utils.arg( '--injected-file-content-bytes', metavar='', type=int, default=None, - help=_('New value for the "injected-file-content-bytes" quota.')) + help=_('New value for the "injected-file-content-bytes" quota.'), + start_version='2.50', end_version='2.56') @utils.arg( '--injected-file-path-bytes', metavar='', type=int, default=None, - help=_('New value for the "injected-file-path-bytes" quota.')) + help=_('New value for the "injected-file-path-bytes" quota.'), + start_version='2.50', end_version='2.56') @utils.arg( '--key-pairs', metavar='', diff --git a/releasenotes/notes/microversion-v2_57-acae2ee11ddae4fb.yaml b/releasenotes/notes/microversion-v2_57-acae2ee11ddae4fb.yaml new file mode 100644 index 000000000..4625d506e --- /dev/null +++ b/releasenotes/notes/microversion-v2_57-acae2ee11ddae4fb.yaml @@ -0,0 +1,31 @@ +--- +features: + - | + Support is added for the 2.57 microversion: + + * A ``userdata`` keyword argument can be passed to the ``Server.rebuild`` + python API binding. If set to None, it will unset any existing userdata + on the server. + * The ``--user-data`` and ``--user-data-unset`` options are added to the + ``nova rebuild`` CLI. The options are mutually exclusive. Specifying + ``--user-data`` will overwrite the existing userdata in the server, and + ``--user-data-unset`` will unset any existing userdata on the server. +upgrade: + - | + Support is added for the 2.57 microversion: + + * The ``--file`` option for the ``nova boot`` and ``nova rebuild`` CLIs is + capped at the 2.56 microversion. Similarly, the ``file`` parameter to + the ``Server.create`` and ``Server.rebuild`` python API binding methods + is capped at 2.56. Users are recommended to use the ``--user-data`` + option instead. + * The ``--injected-files``, ``--injected-file-content-bytes`` and + ``--injected-file-path-bytes`` options are capped at the 2.56 + microversion in the ``nova quota-update`` and ``nova quota-class-update`` + commands. + * The ``maxPersonality`` and ``maxPersonalitySize`` fields are capped at + the 2.56 microversion in the ``nova limits`` command and API binding. + * The ``injected_files``, ``injected_file_content_bytes`` and + ``injected_file_path_bytes`` entries are capped at version 2.56 from + the output of the ``nova quota-show`` and ``nova quota-class-show`` + commands and related python API bindings.