Merge "Fix typo in ssh_floating.json for stress tests"
diff --git a/etc/tempest.conf.sample b/etc/tempest.conf.sample
index f306d8e..d6d0964 100644
--- a/etc/tempest.conf.sample
+++ b/etc/tempest.conf.sample
@@ -95,6 +95,17 @@
#syslog_log_facility=LOG_USER
+[baremetal]
+
+#
+# Options defined in tempest.config
+#
+
+# Catalog type of the baremetal provisioning service. (string
+# value)
+#catalog_type=baremetal
+
+
[boto]
#
@@ -672,6 +683,10 @@
# value)
#savanna=false
+# Whether or not Ironic is expected to be available (boolean
+# value)
+#ironic=false
+
[stress]
diff --git a/tempest/api/baremetal/__init__.py b/tempest/api/baremetal/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tempest/api/baremetal/__init__.py
diff --git a/tempest/api/baremetal/base.py b/tempest/api/baremetal/base.py
new file mode 100644
index 0000000..3aad1b5
--- /dev/null
+++ b/tempest/api/baremetal/base.py
@@ -0,0 +1,171 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import functools
+
+from tempest import clients
+from tempest.common.utils import data_utils
+from tempest import exceptions as exc
+from tempest import test
+
+
+def creates(resource):
+ """Decorator that adds resources to the appropriate cleanup list."""
+
+ def decorator(f):
+ @functools.wraps(f)
+ def wrapper(cls, *args, **kwargs):
+ result = f(cls, *args, **kwargs)
+ body = result[resource]
+
+ if 'uuid' in body:
+ cls.created_objects[resource].add(body['uuid'])
+
+ return result
+ return wrapper
+ return decorator
+
+
+class BaseBaremetalTest(test.BaseTestCase):
+ """Base class for Baremetal API tests."""
+
+ @classmethod
+ def setUpClass(cls):
+ super(BaseBaremetalTest, cls).setUpClass()
+
+ if not cls.config.service_available.ironic:
+ skip_msg = ('%s skipped as Ironic is not available' % cls.__name__)
+ raise cls.skipException(skip_msg)
+
+ mgr = clients.AdminManager()
+ cls.client = mgr.baremetal_client
+
+ cls.created_objects = {'chassis': set(),
+ 'port': set(),
+ 'node': set()}
+
+ @classmethod
+ def tearDownClass(cls):
+ """Ensure that all created objects get destroyed."""
+
+ try:
+ for resource, uuids in cls.created_objects.iteritems():
+ delete_method = getattr(cls.client, 'delete_%s' % resource)
+ for u in uuids:
+ delete_method(u, ignore_errors=exc.NotFound)
+ finally:
+ super(BaseBaremetalTest, cls).tearDownClass()
+
+ @classmethod
+ @creates('chassis')
+ def create_chassis(cls, description=None, expect_errors=False):
+ """
+ Wrapper utility for creating test chassis.
+
+ :param description: A description of the chassis. if not supplied,
+ a random value will be generated.
+ :return: Created chassis.
+
+ """
+ description = description or data_utils.rand_name('test-chassis-')
+ resp, body = cls.client.create_chassis(description=description)
+
+ return {'chassis': body, 'response': resp}
+
+ @classmethod
+ @creates('node')
+ def create_node(cls, chassis_id, cpu_arch='x86', cpu_num=8, storage=1024,
+ memory=4096, driver='fake'):
+ """
+ Wrapper utility for creating test baremetal nodes.
+
+ :param cpu_arch: CPU architecture of the node. Default: x86.
+ :param cpu_num: Number of CPUs. Default: 8.
+ :param storage: Disk size. Default: 1024.
+ :param memory: Available RAM. Default: 4096.
+ :return: Created node.
+
+ """
+ resp, body = cls.client.create_node(chassis_id, cpu_arch=cpu_arch,
+ cpu_num=cpu_num, storage=storage,
+ memory=memory, driver=driver)
+
+ return {'node': body, 'response': resp}
+
+ @classmethod
+ @creates('port')
+ def create_port(cls, node_id, address=None):
+ """
+ Wrapper utility for creating test ports.
+
+ :param address: MAC address of the port. If not supplied, a random
+ value will be generated.
+ :return: Created port.
+
+ """
+ address = address or data_utils.rand_mac_address()
+ resp, body = cls.client.create_port(address=address, node_id=node_id)
+
+ return {'port': body, 'response': resp}
+
+ @classmethod
+ def delete_chassis(cls, chassis_id):
+ """
+ Deletes a chassis having the specified UUID.
+
+ :param uuid: The unique identifier of the chassis.
+ :return: Server response.
+
+ """
+
+ resp, body = cls.client.delete_chassis(chassis_id)
+
+ if chassis_id in cls.created_objects['chassis']:
+ cls.created_objects['chassis'].remove(chassis_id)
+
+ return resp
+
+ @classmethod
+ def delete_node(cls, node_id):
+ """
+ Deletes a node having the specified UUID.
+
+ :param uuid: The unique identifier of the node.
+ :return: Server response.
+
+ """
+
+ resp, body = cls.client.delete_node(node_id)
+
+ if node_id in cls.created_objects['node']:
+ cls.created_objects['node'].remove(node_id)
+
+ return resp
+
+ @classmethod
+ def delete_port(cls, port_id):
+ """
+ Deletes a port having the specified UUID.
+
+ :param uuid: The unique identifier of the port.
+ :return: Server response.
+
+ """
+
+ resp, body = cls.client.delete_port(port_id)
+
+ if port_id in cls.created_objects['port']:
+ cls.created_objects['port'].remove(port_id)
+
+ return resp
diff --git a/tempest/api/baremetal/test_api_discovery.py b/tempest/api/baremetal/test_api_discovery.py
new file mode 100644
index 0000000..32f3d50
--- /dev/null
+++ b/tempest/api/baremetal/test_api_discovery.py
@@ -0,0 +1,46 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.baremetal import base
+from tempest import test
+
+
+class TestApiDiscovery(base.BaseBaremetalTest):
+ """Tests for API discovery features."""
+
+ @test.attr(type='smoke')
+ def test_api_versions(self):
+ resp, descr = self.client.get_api_description()
+ expected_versions = ('v1',)
+
+ versions = [version['id'] for version in descr['versions']]
+
+ for v in expected_versions:
+ self.assertIn(v, versions)
+
+ @test.attr(type='smoke')
+ def test_default_version(self):
+ resp, descr = self.client.get_api_description()
+ default_version = descr['default_version']
+
+ self.assertEqual(default_version['id'], 'v1')
+
+ @test.attr(type='smoke')
+ def test_version_1_resources(self):
+ resp, descr = self.client.get_version_description(version='v1')
+ expected_resources = ('nodes', 'chassis',
+ 'ports', 'links', 'media_types')
+
+ for res in expected_resources:
+ self.assertIn(res, descr)
diff --git a/tempest/api/baremetal/test_chassis.py b/tempest/api/baremetal/test_chassis.py
new file mode 100644
index 0000000..35a93ca
--- /dev/null
+++ b/tempest/api/baremetal/test_chassis.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.baremetal import base
+from tempest.common.utils import data_utils
+from tempest import exceptions as exc
+from tempest import test
+
+
+class TestChassis(base.BaseBaremetalTest):
+ """Tests for chassis."""
+
+ @test.attr(type='smoke')
+ def test_create_chassis(self):
+ descr = data_utils.rand_name('test-chassis-')
+ ch = self.create_chassis(description=descr)['chassis']
+
+ self.assertEqual(ch['description'], descr)
+
+ @test.attr(type='smoke')
+ def test_create_chassis_unicode_description(self):
+ # Use a unicode string for testing:
+ # 'We ♡ OpenStack in Ukraine'
+ descr = u'В Україні ♡ OpenStack!'
+ ch = self.create_chassis(description=descr)['chassis']
+
+ self.assertEqual(ch['description'], descr)
+
+ @test.attr(type='smoke')
+ def test_show_chassis(self):
+ descr = data_utils.rand_name('test-chassis-')
+ uuid = self.create_chassis(description=descr)['chassis']['uuid']
+
+ resp, chassis = self.client.show_chassis(uuid)
+
+ self.assertEqual(chassis['uuid'], uuid)
+ self.assertEqual(chassis['description'], descr)
+
+ @test.attr(type="smoke")
+ def test_list_chassis(self):
+ created_ids = [self.create_chassis()['chassis']['uuid']
+ for i in range(0, 5)]
+
+ resp, body = self.client.list_chassis()
+ loaded_ids = [ch['uuid'] for ch in body['chassis']]
+
+ for i in created_ids:
+ self.assertIn(i, loaded_ids)
+
+ @test.attr(type='smoke')
+ def test_delete_chassis(self):
+ uuid = self.create_chassis()['chassis']['uuid']
+
+ self.delete_chassis(uuid)
+
+ self.assertRaises(exc.NotFound, self.client.show_chassis, uuid)
+
+ @test.attr(type='smoke')
+ def test_update_chassis(self):
+ chassis_id = self.create_chassis()['chassis']['uuid']
+
+ new_description = data_utils.rand_name('new-description-')
+ self.client.update_chassis(chassis_id, description=new_description)
+
+ resp, chassis = self.client.show_chassis(chassis_id)
+ self.assertEqual(chassis['description'], new_description)
diff --git a/tempest/api/baremetal/test_nodes.py b/tempest/api/baremetal/test_nodes.py
new file mode 100644
index 0000000..f9b65ed
--- /dev/null
+++ b/tempest/api/baremetal/test_nodes.py
@@ -0,0 +1,97 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import six
+
+from tempest.api.baremetal import base
+from tempest import exceptions as exc
+from tempest import test
+
+
+class TestNodes(base.BaseBaremetalTest):
+ '''Tests for baremetal nodes.'''
+
+ def setUp(self):
+ super(TestNodes, self).setUp()
+
+ self.chassis = self.create_chassis()['chassis']
+
+ @test.attr(type='smoke')
+ def test_create_node(self):
+ params = {'cpu_arch': 'x86_64',
+ 'cpu_num': '12',
+ 'storage': '10240',
+ 'memory': '1024'}
+
+ node = self.create_node(self.chassis['uuid'], **params)['node']
+
+ for key in params:
+ self.assertEqual(node['properties'][key], params[key])
+
+ @test.attr(type='smoke')
+ def test_delete_node(self):
+ node = self.create_node(self.chassis['uuid'])['node']
+ node_id = node['uuid']
+
+ resp = self.delete_node(node_id)
+
+ self.assertEqual(resp['status'], '204')
+ self.assertRaises(exc.NotFound, self.client.show_node, node_id)
+
+ @test.attr(type='smoke')
+ def test_show_node(self):
+ params = {'cpu_arch': 'x86_64',
+ 'cpu_num': '4',
+ 'storage': '100',
+ 'memory': '512'}
+
+ created_node = self.create_node(self.chassis['uuid'], **params)['node']
+ resp, loaded_node = self.client.show_node(created_node['uuid'])
+
+ for key, val in created_node.iteritems():
+ if key not in ('created_at', 'updated_at'):
+ self.assertEqual(loaded_node[key], val)
+
+ @test.attr(type='smoke')
+ def test_list_nodes(self):
+ uuids = [self.create_node(self.chassis['uuid'])['node']['uuid']
+ for i in range(0, 5)]
+
+ resp, body = self.client.list_nodes()
+ loaded_uuids = [n['uuid'] for n in body['nodes']]
+
+ for u in uuids:
+ self.assertIn(u, loaded_uuids)
+
+ @test.attr(type='smoke')
+ def test_update_node(self):
+ props = {'cpu_arch': 'x86_64',
+ 'cpu_num': '12',
+ 'storage': '10',
+ 'memory': '128'}
+
+ node = self.create_node(self.chassis['uuid'], **props)['node']
+ node_id = node['uuid']
+
+ new_props = {'cpu_arch': 'x86',
+ 'cpu_num': '1',
+ 'storage': '10000',
+ 'memory': '12300'}
+
+ self.client.update_node(node_id, properties=new_props)
+ resp, node = self.client.show_node(node_id)
+
+ for name, value in six.iteritems(new_props):
+ if name not in ('created_at', 'updated_at'):
+ self.assertEqual(node['properties'][name], value)
diff --git a/tempest/api/baremetal/test_ports.py b/tempest/api/baremetal/test_ports.py
new file mode 100644
index 0000000..8249705
--- /dev/null
+++ b/tempest/api/baremetal/test_ports.py
@@ -0,0 +1,85 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.baremetal import base
+from tempest.common.utils import data_utils
+from tempest import exceptions as exc
+from tempest import test
+
+
+class TestPorts(base.BaseBaremetalTest):
+ """Tests for ports."""
+
+ def setUp(self):
+ super(TestPorts, self).setUp()
+
+ chassis = self.create_chassis()['chassis']
+ self.node = self.create_node(chassis['uuid'])['node']
+
+ @test.attr(type='smoke')
+ def test_create_port(self):
+ node_id = self.node['uuid']
+ address = data_utils.rand_mac_address()
+
+ port = self.create_port(node_id=node_id, address=address)['port']
+
+ self.assertEqual(port['address'], address)
+ self.assertEqual(port['node_uuid'], node_id)
+
+ @test.attr(type='smoke')
+ def test_delete_port(self):
+ node_id = self.node['uuid']
+ port_id = self.create_port(node_id=node_id)['port']['uuid']
+
+ resp = self.delete_port(port_id)
+
+ self.assertEqual(resp['status'], '204')
+ self.assertRaises(exc.NotFound, self.client.show_port, port_id)
+
+ @test.attr(type='smoke')
+ def test_show_port(self):
+ node_id = self.node['uuid']
+ address = data_utils.rand_mac_address()
+
+ port_id = self.create_port(node_id=node_id,
+ address=address)['port']['uuid']
+
+ resp, port = self.client.show_port(port_id)
+
+ self.assertEqual(port['uuid'], port_id)
+ self.assertEqual(port['address'], address)
+
+ @test.attr(type='smoke')
+ def test_list_ports(self):
+ node_id = self.node['uuid']
+
+ uuids = [self.create_port(node_id=node_id)['port']['uuid']
+ for i in range(0, 5)]
+
+ resp, body = self.client.list_ports()
+ loaded_uuids = [p['uuid'] for p in body['ports']]
+
+ for u in uuids:
+ self.assertIn(u, loaded_uuids)
+
+ @test.attr(type='smoke')
+ def test_update_port(self):
+ node_id = self.node['uuid']
+ port_id = self.create_port(node_id=node_id)['port']['uuid']
+
+ new_address = data_utils.rand_mac_address()
+ self.client.update_port(port_id, address=new_address)
+
+ resp, body = self.client.show_port(port_id)
+ self.assertEqual(body['address'], new_address)
diff --git a/tempest/api/baremetal/test_ports_negative.py b/tempest/api/baremetal/test_ports_negative.py
new file mode 100644
index 0000000..423313cb
--- /dev/null
+++ b/tempest/api/baremetal/test_ports_negative.py
@@ -0,0 +1,42 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.baremetal import base
+from tempest.common.utils import data_utils
+from tempest import exceptions as exc
+from tempest import test
+
+
+class TestPortsNegative(base.BaseBaremetalTest):
+ """Negative tests for ports."""
+
+ def setUp(self):
+ super(TestPortsNegative, self).setUp()
+
+ chassis = self.create_chassis()['chassis']
+ self.node = self.create_node(chassis['uuid'])['node']
+
+ @test.attr(type='negative')
+ def test_create_port_invalid_mac(self):
+ node_id = self.node['uuid']
+ address = 'not an uuid'
+
+ self.assertRaises(exc.BadRequest,
+ self.create_port, node_id=node_id, address=address)
+
+ @test.attr(type='negative')
+ def test_create_port_wrong_node_id(self):
+ node_id = str(data_utils.rand_uuid())
+
+ self.assertRaises(exc.BadRequest, self.create_port, node_id=node_id)
diff --git a/tempest/api/compute/base.py b/tempest/api/compute/base.py
index b060f15..311e158 100644
--- a/tempest/api/compute/base.py
+++ b/tempest/api/compute/base.py
@@ -130,12 +130,22 @@
r, b = cls.servers_client.list_servers()
servers = [s for s in b['servers'] if s['name'].startswith(name)]
- cls.servers.extend(servers)
-
if 'wait_until' in kwargs:
for server in servers:
- cls.servers_client.wait_for_server_status(
- server['id'], kwargs['wait_until'])
+ try:
+ cls.servers_client.wait_for_server_status(
+ server['id'], kwargs['wait_until'])
+ except Exception as ex:
+ if ('preserve_server_on_error' not in kwargs
+ or kwargs['preserve_server_on_error'] is False):
+ for server in servers:
+ try:
+ cls.servers_client.delete_server(server['id'])
+ except Exception:
+ pass
+ raise ex
+
+ cls.servers.extend(servers)
return resp, body
diff --git a/tempest/api/compute/v3/images/test_image_metadata.py b/tempest/api/compute/v3/images/test_image_metadata.py
new file mode 100644
index 0000000..76e0cae
--- /dev/null
+++ b/tempest/api/compute/v3/images/test_image_metadata.py
@@ -0,0 +1,114 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack Foundation
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.compute import base
+from tempest.common.utils import data_utils
+from tempest.test import attr
+
+
+class ImagesMetadataTestJSON(base.BaseV2ComputeTest):
+ _interface = 'json'
+
+ @classmethod
+ def setUpClass(cls):
+ super(ImagesMetadataTestJSON, cls).setUpClass()
+ if not cls.config.service_available.glance:
+ skip_msg = ("%s skipped as glance is not available" % cls.__name__)
+ raise cls.skipException(skip_msg)
+
+ cls.servers_client = cls.servers_client
+ cls.client = cls.images_client
+
+ resp, server = cls.create_test_server(wait_until='ACTIVE')
+ cls.server_id = server['id']
+
+ # Snapshot the server once to save time
+ name = data_utils.rand_name('image')
+ resp, _ = cls.client.create_image(cls.server_id, name, {})
+ cls.image_id = resp['location'].rsplit('/', 1)[1]
+
+ cls.client.wait_for_image_status(cls.image_id, 'ACTIVE')
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.client.delete_image(cls.image_id)
+ super(ImagesMetadataTestJSON, cls).tearDownClass()
+
+ def setUp(self):
+ super(ImagesMetadataTestJSON, self).setUp()
+ meta = {'key1': 'value1', 'key2': 'value2'}
+ resp, _ = self.client.set_image_metadata(self.image_id, meta)
+ self.assertEqual(resp.status, 200)
+
+ @attr(type='gate')
+ def test_list_image_metadata(self):
+ # All metadata key/value pairs for an image should be returned
+ resp, resp_metadata = self.client.list_image_metadata(self.image_id)
+ expected = {'key1': 'value1', 'key2': 'value2'}
+ self.assertEqual(expected, resp_metadata)
+
+ @attr(type='gate')
+ def test_set_image_metadata(self):
+ # The metadata for the image should match the new values
+ req_metadata = {'meta2': 'value2', 'meta3': 'value3'}
+ resp, body = self.client.set_image_metadata(self.image_id,
+ req_metadata)
+
+ resp, resp_metadata = self.client.list_image_metadata(self.image_id)
+ self.assertEqual(req_metadata, resp_metadata)
+
+ @attr(type='gate')
+ def test_update_image_metadata(self):
+ # The metadata for the image should match the updated values
+ req_metadata = {'key1': 'alt1', 'key3': 'value3'}
+ resp, metadata = self.client.update_image_metadata(self.image_id,
+ req_metadata)
+
+ resp, resp_metadata = self.client.list_image_metadata(self.image_id)
+ expected = {'key1': 'alt1', 'key2': 'value2', 'key3': 'value3'}
+ self.assertEqual(expected, resp_metadata)
+
+ @attr(type='gate')
+ def test_get_image_metadata_item(self):
+ # The value for a specific metadata key should be returned
+ resp, meta = self.client.get_image_metadata_item(self.image_id,
+ 'key2')
+ self.assertEqual('value2', meta['key2'])
+
+ @attr(type='gate')
+ def test_set_image_metadata_item(self):
+ # The value provided for the given meta item should be set for
+ # the image
+ meta = {'key1': 'alt'}
+ resp, body = self.client.set_image_metadata_item(self.image_id,
+ 'key1', meta)
+ resp, resp_metadata = self.client.list_image_metadata(self.image_id)
+ expected = {'key1': 'alt', 'key2': 'value2'}
+ self.assertEqual(expected, resp_metadata)
+
+ @attr(type='gate')
+ def test_delete_image_metadata_item(self):
+ # The metadata value/key pair should be deleted from the image
+ resp, body = self.client.delete_image_metadata_item(self.image_id,
+ 'key1')
+ resp, resp_metadata = self.client.list_image_metadata(self.image_id)
+ expected = {'key2': 'value2'}
+ self.assertEqual(expected, resp_metadata)
+
+
+class ImagesMetadataTestXML(ImagesMetadataTestJSON):
+ _interface = 'xml'
diff --git a/tempest/api/compute/v3/images/test_image_metadata_negative.py b/tempest/api/compute/v3/images/test_image_metadata_negative.py
new file mode 100644
index 0000000..1767e5d
--- /dev/null
+++ b/tempest/api/compute/v3/images/test_image_metadata_negative.py
@@ -0,0 +1,81 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 OpenStack Foundation
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.compute import base
+from tempest.common.utils import data_utils
+from tempest import exceptions
+from tempest.test import attr
+
+
+class ImagesMetadataTestJSON(base.BaseV2ComputeTest):
+ _interface = 'json'
+
+ @classmethod
+ def setUpClass(cls):
+ super(ImagesMetadataTestJSON, cls).setUpClass()
+ cls.client = cls.images_client
+
+ @attr(type=['negative', 'gate'])
+ def test_list_nonexistent_image_metadata(self):
+ # Negative test: List on nonexistent image
+ # metadata should not happen
+ self.assertRaises(exceptions.NotFound, self.client.list_image_metadata,
+ data_utils.rand_uuid())
+
+ @attr(type=['negative', 'gate'])
+ def test_update_nonexistent_image_metadata(self):
+ # Negative test:An update should not happen for a non-existent image
+ meta = {'key1': 'alt1', 'key2': 'alt2'}
+ self.assertRaises(exceptions.NotFound,
+ self.client.update_image_metadata,
+ data_utils.rand_uuid(), meta)
+
+ @attr(type=['negative', 'gate'])
+ def test_get_nonexistent_image_metadata_item(self):
+ # Negative test: Get on non-existent image should not happen
+ self.assertRaises(exceptions.NotFound,
+ self.client.get_image_metadata_item,
+ data_utils.rand_uuid(), 'key2')
+
+ @attr(type=['negative', 'gate'])
+ def test_set_nonexistent_image_metadata(self):
+ # Negative test: Metadata should not be set to a non-existent image
+ meta = {'key1': 'alt1', 'key2': 'alt2'}
+ self.assertRaises(exceptions.NotFound, self.client.set_image_metadata,
+ data_utils.rand_uuid(), meta)
+
+ @attr(type=['negative', 'gate'])
+ def test_set_nonexistent_image_metadata_item(self):
+ # Negative test: Metadata item should not be set to a
+ # nonexistent image
+ meta = {'key1': 'alt'}
+ self.assertRaises(exceptions.NotFound,
+ self.client.set_image_metadata_item,
+ data_utils.rand_uuid(), 'key1',
+ meta)
+
+ @attr(type=['negative', 'gate'])
+ def test_delete_nonexistent_image_metadata_item(self):
+ # Negative test: Shouldn't be able to delete metadata
+ # item from non-existent image
+ self.assertRaises(exceptions.NotFound,
+ self.client.delete_image_metadata_item,
+ data_utils.rand_uuid(), 'key1')
+
+
+class ImagesMetadataTestXML(ImagesMetadataTestJSON):
+ _interface = 'xml'
diff --git a/tempest/api/compute/v3/images/test_images_oneserver.py b/tempest/api/compute/v3/images/test_images_oneserver.py
new file mode 100644
index 0000000..26cc3f6
--- /dev/null
+++ b/tempest/api/compute/v3/images/test_images_oneserver.py
@@ -0,0 +1,138 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack Foundation
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import testtools
+
+from tempest.api.compute import base
+from tempest import clients
+from tempest.common.utils import data_utils
+from tempest import config
+from tempest.openstack.common import log as logging
+from tempest.test import attr
+
+CONF = config.CONF
+LOG = logging.getLogger(__name__)
+
+
+class ImagesOneServerTestJSON(base.BaseV2ComputeTest):
+ _interface = 'json'
+
+ def tearDown(self):
+ """Terminate test instances created after a test is executed."""
+ for image_id in self.image_ids:
+ self.client.delete_image(image_id)
+ self.image_ids.remove(image_id)
+ super(ImagesOneServerTestJSON, self).tearDown()
+
+ def setUp(self):
+ # NOTE(afazekas): Normally we use the same server with all test cases,
+ # but if it has an issue, we build a new one
+ super(ImagesOneServerTestJSON, self).setUp()
+ # Check if the server is in a clean state after test
+ try:
+ self.servers_client.wait_for_server_status(self.server_id,
+ 'ACTIVE')
+ except Exception:
+ LOG.exception('server %s timed out to become ACTIVE. rebuilding'
+ % self.server_id)
+ # Rebuild server if cannot reach the ACTIVE state
+ # Usually it means the server had a serious accident
+ self.__class__.server_id = self.rebuild_server(self.server_id)
+
+ @classmethod
+ def setUpClass(cls):
+ super(ImagesOneServerTestJSON, cls).setUpClass()
+ cls.client = cls.images_client
+ if not cls.config.service_available.glance:
+ skip_msg = ("%s skipped as glance is not available" % cls.__name__)
+ raise cls.skipException(skip_msg)
+
+ try:
+ resp, server = cls.create_test_server(wait_until='ACTIVE')
+ cls.server_id = server['id']
+ except Exception:
+ cls.tearDownClass()
+ raise
+
+ cls.image_ids = []
+
+ if cls.multi_user:
+ if cls.config.compute.allow_tenant_isolation:
+ creds = cls.isolated_creds.get_alt_creds()
+ username, tenant_name, password = creds
+ cls.alt_manager = clients.Manager(username=username,
+ password=password,
+ tenant_name=tenant_name)
+ else:
+ # Use the alt_XXX credentials in the config file
+ cls.alt_manager = clients.AltManager()
+ cls.alt_client = cls.alt_manager.images_client
+
+ def _get_default_flavor_disk_size(self, flavor_id):
+ resp, flavor = self.flavors_client.get_flavor_details(flavor_id)
+ return flavor['disk']
+
+ @testtools.skipUnless(CONF.compute_feature_enabled.create_image,
+ 'Environment unable to create images.')
+ @attr(type='smoke')
+ def test_create_delete_image(self):
+
+ # Create a new image
+ name = data_utils.rand_name('image')
+ meta = {'image_type': 'test'}
+ resp, body = self.client.create_image(self.server_id, name, meta)
+ self.assertEqual(202, resp.status)
+ image_id = data_utils.parse_image_id(resp['location'])
+ self.client.wait_for_image_status(image_id, 'ACTIVE')
+
+ # Verify the image was created correctly
+ resp, image = self.client.get_image(image_id)
+ self.assertEqual(name, image['name'])
+ self.assertEqual('test', image['metadata']['image_type'])
+
+ resp, original_image = self.client.get_image(self.image_ref)
+
+ # Verify minRAM is the same as the original image
+ self.assertEqual(image['minRam'], original_image['minRam'])
+
+ # Verify minDisk is the same as the original image or the flavor size
+ flavor_disk_size = self._get_default_flavor_disk_size(self.flavor_ref)
+ self.assertIn(str(image['minDisk']),
+ (str(original_image['minDisk']), str(flavor_disk_size)))
+
+ # Verify the image was deleted correctly
+ resp, body = self.client.delete_image(image_id)
+ self.assertEqual('204', resp['status'])
+ self.client.wait_for_resource_deletion(image_id)
+
+ @attr(type=['gate'])
+ def test_create_image_specify_multibyte_character_image_name(self):
+ if self.__class__._interface == "xml":
+ # NOTE(sdague): not entirely accurage, but we'd need a ton of work
+ # in our XML client to make this good
+ raise self.skipException("Not testable in XML")
+ # prefix character is:
+ # http://www.fileformat.info/info/unicode/char/1F4A9/index.htm
+ utf8_name = data_utils.rand_name(u'\xF0\x9F\x92\xA9')
+ resp, body = self.client.create_image(self.server_id, utf8_name)
+ image_id = data_utils.parse_image_id(resp['location'])
+ self.addCleanup(self.client.delete_image, image_id)
+ self.assertEqual('202', resp['status'])
+
+
+class ImagesOneServerTestXML(ImagesOneServerTestJSON):
+ _interface = 'xml'
diff --git a/tempest/api/compute/v3/images/test_images_oneserver_negative.py b/tempest/api/compute/v3/images/test_images_oneserver_negative.py
new file mode 100644
index 0000000..5e235d1
--- /dev/null
+++ b/tempest/api/compute/v3/images/test_images_oneserver_negative.py
@@ -0,0 +1,162 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack Foundation
+# Copyright 2013 IBM Corp.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.compute import base
+from tempest import clients
+from tempest.common.utils import data_utils
+from tempest import exceptions
+from tempest.openstack.common import log as logging
+from tempest.test import attr
+from tempest.test import skip_because
+
+LOG = logging.getLogger(__name__)
+
+
+class ImagesOneServerNegativeTestJSON(base.BaseV2ComputeTest):
+ _interface = 'json'
+
+ def tearDown(self):
+ """Terminate test instances created after a test is executed."""
+ for image_id in self.image_ids:
+ self.client.delete_image(image_id)
+ self.image_ids.remove(image_id)
+ super(ImagesOneServerNegativeTestJSON, self).tearDown()
+
+ def setUp(self):
+ # NOTE(afazekas): Normally we use the same server with all test cases,
+ # but if it has an issue, we build a new one
+ super(ImagesOneServerNegativeTestJSON, self).setUp()
+ # Check if the server is in a clean state after test
+ try:
+ self.servers_client.wait_for_server_status(self.server_id,
+ 'ACTIVE')
+ except Exception:
+ LOG.exception('server %s timed out to become ACTIVE. rebuilding'
+ % self.server_id)
+ # Rebuild server if cannot reach the ACTIVE state
+ # Usually it means the server had a serious accident
+ self._reset_server()
+
+ def _reset_server(self):
+ self.__class__.server_id = self.rebuild_server(self.server_id)
+
+ @classmethod
+ def setUpClass(cls):
+ super(ImagesOneServerNegativeTestJSON, cls).setUpClass()
+ cls.client = cls.images_client
+ if not cls.config.service_available.glance:
+ skip_msg = ("%s skipped as glance is not available" % cls.__name__)
+ raise cls.skipException(skip_msg)
+
+ try:
+ resp, server = cls.create_test_server(wait_until='ACTIVE')
+ cls.server_id = server['id']
+ except Exception:
+ cls.tearDownClass()
+ raise
+
+ cls.image_ids = []
+
+ if cls.multi_user:
+ if cls.config.compute.allow_tenant_isolation:
+ creds = cls.isolated_creds.get_alt_creds()
+ username, tenant_name, password = creds
+ cls.alt_manager = clients.Manager(username=username,
+ password=password,
+ tenant_name=tenant_name)
+ else:
+ # Use the alt_XXX credentials in the config file
+ cls.alt_manager = clients.AltManager()
+ cls.alt_client = cls.alt_manager.images_client
+
+ @skip_because(bug="1006725")
+ @attr(type=['negative', 'gate'])
+ def test_create_image_specify_multibyte_character_image_name(self):
+ if self.__class__._interface == "xml":
+ raise self.skipException("Not testable in XML")
+ # invalid multibyte sequence from:
+ # http://stackoverflow.com/questions/1301402/
+ # example-invalid-utf8-string
+ invalid_name = data_utils.rand_name(u'\xc3\x28')
+ self.assertRaises(exceptions.BadRequest,
+ self.client.create_image, self.server_id,
+ invalid_name)
+
+ @attr(type=['negative', 'gate'])
+ def test_create_image_specify_invalid_metadata(self):
+ # Return an error when creating image with invalid metadata
+ snapshot_name = data_utils.rand_name('test-snap-')
+ meta = {'': ''}
+ self.assertRaises(exceptions.BadRequest, self.client.create_image,
+ self.server_id, snapshot_name, meta)
+
+ @attr(type=['negative', 'gate'])
+ def test_create_image_specify_metadata_over_limits(self):
+ # Return an error when creating image with meta data over 256 chars
+ snapshot_name = data_utils.rand_name('test-snap-')
+ meta = {'a' * 260: 'b' * 260}
+ self.assertRaises(exceptions.BadRequest, self.client.create_image,
+ self.server_id, snapshot_name, meta)
+
+ @attr(type=['negative', 'gate'])
+ def test_create_second_image_when_first_image_is_being_saved(self):
+ # Disallow creating another image when first image is being saved
+
+ # Create first snapshot
+ snapshot_name = data_utils.rand_name('test-snap-')
+ resp, body = self.client.create_image(self.server_id,
+ snapshot_name)
+ self.assertEqual(202, resp.status)
+ image_id = data_utils.parse_image_id(resp['location'])
+ self.image_ids.append(image_id)
+ self.addCleanup(self._reset_server)
+
+ # Create second snapshot
+ alt_snapshot_name = data_utils.rand_name('test-snap-')
+ self.assertRaises(exceptions.Conflict, self.client.create_image,
+ self.server_id, alt_snapshot_name)
+
+ @attr(type=['negative', 'gate'])
+ def test_create_image_specify_name_over_256_chars(self):
+ # Return an error if snapshot name over 256 characters is passed
+
+ snapshot_name = data_utils.rand_name('a' * 260)
+ self.assertRaises(exceptions.BadRequest, self.client.create_image,
+ self.server_id, snapshot_name)
+
+ @attr(type=['negative', 'gate'])
+ def test_delete_image_that_is_not_yet_active(self):
+ # Return an error while trying to delete an image what is creating
+
+ snapshot_name = data_utils.rand_name('test-snap-')
+ resp, body = self.client.create_image(self.server_id, snapshot_name)
+ self.assertEqual(202, resp.status)
+ image_id = data_utils.parse_image_id(resp['location'])
+ self.image_ids.append(image_id)
+ self.addCleanup(self._reset_server)
+
+ # Do not wait, attempt to delete the image, ensure it's successful
+ resp, body = self.client.delete_image(image_id)
+ self.assertEqual('204', resp['status'])
+ self.image_ids.remove(image_id)
+
+ self.assertRaises(exceptions.NotFound, self.client.get_image, image_id)
+
+
+class ImagesOneServerNegativeTestXML(ImagesOneServerNegativeTestJSON):
+ _interface = 'xml'
diff --git a/tempest/api/compute/v3/images/test_list_image_filters.py b/tempest/api/compute/v3/images/test_list_image_filters.py
new file mode 100644
index 0000000..bfdd8b2
--- /dev/null
+++ b/tempest/api/compute/v3/images/test_list_image_filters.py
@@ -0,0 +1,231 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack Foundation
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.compute import base
+from tempest import exceptions
+from tempest.openstack.common import log as logging
+from tempest.test import attr
+
+
+LOG = logging.getLogger(__name__)
+
+
+class ListImageFiltersTestJSON(base.BaseV2ComputeTest):
+ _interface = 'json'
+
+ @classmethod
+ def setUpClass(cls):
+ super(ListImageFiltersTestJSON, cls).setUpClass()
+ if not cls.config.service_available.glance:
+ skip_msg = ("%s skipped as glance is not available" % cls.__name__)
+ raise cls.skipException(skip_msg)
+ cls.client = cls.images_client
+ cls.image_ids = []
+
+ try:
+ resp, cls.server1 = cls.create_test_server()
+ resp, cls.server2 = cls.create_test_server(wait_until='ACTIVE')
+ # NOTE(sdague) this is faster than doing the sync wait_util on both
+ cls.servers_client.wait_for_server_status(cls.server1['id'],
+ 'ACTIVE')
+
+ # Create images to be used in the filter tests
+ resp, cls.image1 = cls.create_image_from_server(
+ cls.server1['id'], wait_until='ACTIVE')
+ cls.image1_id = cls.image1['id']
+
+ # Servers have a hidden property for when they are being imaged
+ # Performing back-to-back create image calls on a single
+ # server will sometimes cause failures
+ resp, cls.image3 = cls.create_image_from_server(
+ cls.server2['id'], wait_until='ACTIVE')
+ cls.image3_id = cls.image3['id']
+
+ # Wait for the server to be active after the image upload
+ resp, cls.image2 = cls.create_image_from_server(
+ cls.server1['id'], wait_until='ACTIVE')
+ cls.image2_id = cls.image2['id']
+ except Exception:
+ LOG.exception('setUpClass failed')
+ cls.tearDownClass()
+ raise
+
+ @attr(type=['negative', 'gate'])
+ def test_get_image_not_existing(self):
+ # Check raises a NotFound
+ self.assertRaises(exceptions.NotFound, self.client.get_image,
+ "nonexistingimageid")
+
+ @attr(type='gate')
+ def test_list_images_filter_by_status(self):
+ # The list of images should contain only images with the
+ # provided status
+ params = {'status': 'ACTIVE'}
+ resp, images = self.client.list_images(params)
+
+ self.assertTrue(any([i for i in images if i['id'] == self.image1_id]))
+ self.assertTrue(any([i for i in images if i['id'] == self.image2_id]))
+ self.assertTrue(any([i for i in images if i['id'] == self.image3_id]))
+
+ @attr(type='gate')
+ def test_list_images_filter_by_name(self):
+ # List of all images should contain the expected images filtered
+ # by name
+ params = {'name': self.image1['name']}
+ resp, images = self.client.list_images(params)
+
+ self.assertTrue(any([i for i in images if i['id'] == self.image1_id]))
+ self.assertFalse(any([i for i in images if i['id'] == self.image2_id]))
+ self.assertFalse(any([i for i in images if i['id'] == self.image3_id]))
+
+ @attr(type='gate')
+ def test_list_images_filter_by_server_id(self):
+ # The images should contain images filtered by server id
+ params = {'server': self.server1['id']}
+ resp, images = self.client.list_images(params)
+
+ self.assertTrue(any([i for i in images if i['id'] == self.image1_id]),
+ "Failed to find image %s in images. Got images %s" %
+ (self.image1_id, images))
+ self.assertTrue(any([i for i in images if i['id'] == self.image2_id]))
+ self.assertFalse(any([i for i in images if i['id'] == self.image3_id]))
+
+ @attr(type='gate')
+ def test_list_images_filter_by_server_ref(self):
+ # The list of servers should be filtered by server ref
+ server_links = self.server2['links']
+
+ # Try all server link types
+ for link in server_links:
+ params = {'server': link['href']}
+ resp, images = self.client.list_images(params)
+
+ self.assertFalse(any([i for i in images
+ if i['id'] == self.image1_id]))
+ self.assertFalse(any([i for i in images
+ if i['id'] == self.image2_id]))
+ self.assertTrue(any([i for i in images
+ if i['id'] == self.image3_id]))
+
+ @attr(type='gate')
+ def test_list_images_filter_by_type(self):
+ # The list of servers should be filtered by image type
+ params = {'type': 'snapshot'}
+ resp, images = self.client.list_images(params)
+
+ self.assertTrue(any([i for i in images if i['id'] == self.image1_id]))
+ self.assertTrue(any([i for i in images if i['id'] == self.image2_id]))
+ self.assertTrue(any([i for i in images if i['id'] == self.image3_id]))
+ self.assertFalse(any([i for i in images if i['id'] == self.image_ref]))
+
+ @attr(type='gate')
+ def test_list_images_limit_results(self):
+ # Verify only the expected number of results are returned
+ params = {'limit': '1'}
+ resp, images = self.client.list_images(params)
+ # when _interface='xml', one element for images_links in images
+ # ref: Question #224349
+ self.assertEqual(1, len([x for x in images if 'id' in x]))
+
+ @attr(type='gate')
+ def test_list_images_filter_by_changes_since(self):
+ # Verify only updated images are returned in the detailed list
+
+ # Becoming ACTIVE will modify the updated time
+ # Filter by the image's created time
+ params = {'changes-since': self.image3['created']}
+ resp, images = self.client.list_images(params)
+ found = any([i for i in images if i['id'] == self.image3_id])
+ self.assertTrue(found)
+
+ @attr(type='gate')
+ def test_list_images_with_detail_filter_by_status(self):
+ # Detailed list of all images should only contain images
+ # with the provided status
+ params = {'status': 'ACTIVE'}
+ resp, images = self.client.list_images_with_detail(params)
+
+ self.assertTrue(any([i for i in images if i['id'] == self.image1_id]))
+ self.assertTrue(any([i for i in images if i['id'] == self.image2_id]))
+ self.assertTrue(any([i for i in images if i['id'] == self.image3_id]))
+
+ @attr(type='gate')
+ def test_list_images_with_detail_filter_by_name(self):
+ # Detailed list of all images should contain the expected
+ # images filtered by name
+ params = {'name': self.image1['name']}
+ resp, images = self.client.list_images_with_detail(params)
+
+ self.assertTrue(any([i for i in images if i['id'] == self.image1_id]))
+ self.assertFalse(any([i for i in images if i['id'] == self.image2_id]))
+ self.assertFalse(any([i for i in images if i['id'] == self.image3_id]))
+
+ @attr(type='gate')
+ def test_list_images_with_detail_limit_results(self):
+ # Verify only the expected number of results (with full details)
+ # are returned
+ params = {'limit': '1'}
+ resp, images = self.client.list_images_with_detail(params)
+ self.assertEqual(1, len(images))
+
+ @attr(type='gate')
+ def test_list_images_with_detail_filter_by_server_ref(self):
+ # Detailed list of servers should be filtered by server ref
+ server_links = self.server2['links']
+
+ # Try all server link types
+ for link in server_links:
+ params = {'server': link['href']}
+ resp, images = self.client.list_images_with_detail(params)
+
+ self.assertFalse(any([i for i in images
+ if i['id'] == self.image1_id]))
+ self.assertFalse(any([i for i in images
+ if i['id'] == self.image2_id]))
+ self.assertTrue(any([i for i in images
+ if i['id'] == self.image3_id]))
+
+ @attr(type='gate')
+ def test_list_images_with_detail_filter_by_type(self):
+ # The detailed list of servers should be filtered by image type
+ params = {'type': 'snapshot'}
+ resp, images = self.client.list_images_with_detail(params)
+ resp, image4 = self.client.get_image(self.image_ref)
+
+ self.assertTrue(any([i for i in images if i['id'] == self.image1_id]))
+ self.assertTrue(any([i for i in images if i['id'] == self.image2_id]))
+ self.assertTrue(any([i for i in images if i['id'] == self.image3_id]))
+ self.assertFalse(any([i for i in images if i['id'] == self.image_ref]))
+
+ @attr(type='gate')
+ def test_list_images_with_detail_filter_by_changes_since(self):
+ # Verify an update image is returned
+
+ # Becoming ACTIVE will modify the updated time
+ # Filter by the image's created time
+ params = {'changes-since': self.image1['created']}
+ resp, images = self.client.list_images_with_detail(params)
+ self.assertTrue(any([i for i in images if i['id'] == self.image1_id]))
+
+ @attr(type=['negative', 'gate'])
+ def test_get_nonexistant_image(self):
+ # Negative test: GET on non-existent image should fail
+ self.assertRaises(exceptions.NotFound, self.client.get_image, 999)
+
+
+class ListImageFiltersTestXML(ListImageFiltersTestJSON):
+ _interface = 'xml'
diff --git a/tempest/api/telemetry/__init__.py b/tempest/api/telemetry/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tempest/api/telemetry/__init__.py
diff --git a/tempest/api/telemetry/base.py b/tempest/api/telemetry/base.py
new file mode 100644
index 0000000..1f661a6
--- /dev/null
+++ b/tempest/api/telemetry/base.py
@@ -0,0 +1,27 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest import config
+import tempest.test
+
+CONF = config.CONF
+
+
+class BaseTelemetryTest(tempest.test.BaseTestCase):
+
+ """Base test case class for all Telemetry API tests."""
+
+ @classmethod
+ def setUpClass(cls):
+ super(BaseTelemetryTest, cls).setUpClass()
+ if not CONF.service_available.ceilometer:
+ raise cls.skipException("Ceilometer support is required")
diff --git a/tempest/api/volume/test_snapshot_metadata.py b/tempest/api/volume/test_snapshot_metadata.py
new file mode 100644
index 0000000..0326f3c
--- /dev/null
+++ b/tempest/api/volume/test_snapshot_metadata.py
@@ -0,0 +1,119 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 Huawei Technologies Co.,LTD
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.api.volume import base
+from tempest import test
+
+
+class SnapshotMetadataTest(base.BaseVolumeV1Test):
+ _interface = "json"
+
+ @classmethod
+ def setUpClass(cls):
+ super(SnapshotMetadataTest, cls).setUpClass()
+ cls.client = cls.snapshots_client
+ # Create a volume
+ cls.volume = cls.create_volume()
+ # Create a snapshot
+ cls.snapshot = cls.create_snapshot(volume_id=cls.volume['id'])
+ cls.snapshot_id = cls.snapshot['id']
+
+ def tearDown(self):
+ # Update the metadata to {}
+ self.client.update_snapshot_metadata(self.snapshot_id, {})
+ super(SnapshotMetadataTest, self).tearDown()
+
+ @test.attr(type='gate')
+ def test_create_get_delete_snapshot_metadata(self):
+ # Create metadata for the snapshot
+ metadata = {"key1": "value1",
+ "key2": "value2",
+ "key3": "value3"}
+ expected = {"key2": "value2",
+ "key3": "value3"}
+ resp, body = self.client.create_snapshot_metadata(self.snapshot_id,
+ metadata)
+ self.assertEqual(200, resp.status)
+ # Get the metadata of the snapshot
+ resp, body = self.client.get_snapshot_metadata(self.snapshot_id)
+ self.assertEqual(200, resp.status)
+ self.assertEqual(metadata, body)
+ # Delete one item metadata of the snapshot
+ resp, body = self.client.delete_snapshot_metadata_item(
+ self.snapshot_id,
+ "key1")
+ self.assertEqual(200, resp.status)
+ resp, body = self.client.get_snapshot_metadata(self.snapshot_id)
+ self.assertEqual(expected, body)
+
+ @test.attr(type='gate')
+ def test_update_snapshot_metadata(self):
+ # Update metadata for the snapshot
+ metadata = {"key1": "value1",
+ "key2": "value2",
+ "key3": "value3"}
+ update = {"key3": "value3_update",
+ "key4": "value4"}
+ # Create metadata for the snapshot
+ resp, body = self.client.create_snapshot_metadata(self.snapshot_id,
+ metadata)
+ self.assertEqual(200, resp.status)
+ # Get the metadata of the snapshot
+ resp, body = self.client.get_snapshot_metadata(self.snapshot_id)
+ self.assertEqual(200, resp.status)
+ self.assertEqual(metadata, body)
+ # Update metadata item
+ resp, body = self.client.update_snapshot_metadata(
+ self.snapshot_id,
+ update)
+ self.assertEqual(200, resp.status)
+ # Get the metadata of the snapshot
+ resp, body = self.client.get_snapshot_metadata(self.snapshot_id)
+ self.assertEqual(200, resp.status)
+ self.assertEqual(update, body)
+
+ @test.attr(type='gate')
+ def test_update_snapshot_metadata_item(self):
+ # Update metadata item for the snapshot
+ metadata = {"key1": "value1",
+ "key2": "value2",
+ "key3": "value3"}
+ update_item = {"key3": "value3_update"}
+ expect = {"key1": "value1",
+ "key2": "value2",
+ "key3": "value3_update"}
+ # Create metadata for the snapshot
+ resp, body = self.client.create_snapshot_metadata(self.snapshot_id,
+ metadata)
+ self.assertEqual(200, resp.status)
+ # Get the metadata of the snapshot
+ resp, body = self.client.get_snapshot_metadata(self.snapshot_id)
+ self.assertEqual(metadata, body)
+ # Update metadata item
+ resp, body = self.client.update_snapshot_metadata_item(
+ self.snapshot_id,
+ "key3",
+ update_item)
+ self.assertEqual(200, resp.status)
+ # Get the metadata of the snapshot
+ resp, body = self.client.get_snapshot_metadata(self.snapshot_id)
+ self.assertEqual(200, resp.status)
+ self.assertEqual(expect, body)
+
+
+class SnapshotMetadataTestXML(SnapshotMetadataTest):
+ _interface = "xml"
diff --git a/tempest/clients.py b/tempest/clients.py
index 83b72c6..519d191 100644
--- a/tempest/clients.py
+++ b/tempest/clients.py
@@ -18,6 +18,8 @@
from tempest import config
from tempest import exceptions
from tempest.openstack.common import log as logging
+from tempest.services.baremetal.v1.client_json import BaremetalClientJSON
+from tempest.services.baremetal.v1.client_xml import BaremetalClientXML
from tempest.services import botoclients
from tempest.services.compute.json.aggregates_client import \
AggregatesClientJSON
@@ -232,6 +234,7 @@
if interface == 'xml':
self.certificates_client = CertificatesClientXML(*client_args)
self.certificates_v3_client = CertificatesV3ClientXML(*client_args)
+ self.baremetal_client = BaremetalClientXML(*client_args)
self.servers_client = ServersClientXML(*client_args)
self.servers_v3_client = ServersV3ClientXML(*client_args)
self.limits_client = LimitsClientXML(*client_args)
@@ -294,6 +297,7 @@
self.certificates_client = CertificatesClientJSON(*client_args)
self.certificates_v3_client = CertificatesV3ClientJSON(
*client_args)
+ self.baremetal_client = BaremetalClientJSON(*client_args)
self.servers_client = ServersClientJSON(*client_args)
self.servers_v3_client = ServersV3ClientJSON(*client_args)
self.limits_client = LimitsClientJSON(*client_args)
diff --git a/tempest/common/custom_matchers.py b/tempest/common/custom_matchers.py
index 307d5db..81b5153 100644
--- a/tempest/common/custom_matchers.py
+++ b/tempest/common/custom_matchers.py
@@ -125,7 +125,7 @@
elif key == 'content-type' and not value:
return InvalidFormat(key, value)
elif key == 'x-trans-id' and \
- not re.match("^tx[0-9a-f]*-[0-9a-f]*$", value):
+ not re.match("^tx[0-9a-f]{21}-[0-9a-f]{10}.*", value):
return InvalidFormat(key, value)
elif key == 'date' and not value:
return InvalidFormat(key, value)
diff --git a/tempest/common/utils/data_utils.py b/tempest/common/utils/data_utils.py
index 4f93e1c..339d22a 100644
--- a/tempest/common/utils/data_utils.py
+++ b/tempest/common/utils/data_utils.py
@@ -40,6 +40,21 @@
return random.randint(start, end)
+def rand_mac_address():
+ """Generate an Ethernet MAC address."""
+ # NOTE(vish): We would prefer to use 0xfe here to ensure that linux
+ # bridge mac addresses don't change, but it appears to
+ # conflict with libvirt, so we use the next highest octet
+ # that has the unicast and locally administered bits set
+ # properly: 0xfa.
+ # Discussion: https://bugs.launchpad.net/nova/+bug/921838
+ mac = [0xfa, 0x16, 0x3e,
+ random.randint(0x00, 0xff),
+ random.randint(0x00, 0xff),
+ random.randint(0x00, 0xff)]
+ return ':'.join(["%02x" % x for x in mac])
+
+
def build_url(host, port, api_version=None, path=None,
params=None, use_ssl=False):
"""Build the request URL from given host, port, path and parameters."""
diff --git a/tempest/config.py b/tempest/config.py
index d42edc9..8d6e52a 100644
--- a/tempest/config.py
+++ b/tempest/config.py
@@ -644,6 +644,9 @@
cfg.BoolOpt('savanna',
default=False,
help="Whether or not Savanna is expected to be available"),
+ cfg.BoolOpt('ironic',
+ default=False,
+ help="Whether or not Ironic is expected to be available"),
]
debug_group = cfg.OptGroup(name="debug",
@@ -656,6 +659,16 @@
]
+baremetal_group = cfg.OptGroup(name='baremetal',
+ title='Baremetal provisioning service options')
+
+BaremetalGroup = [
+ cfg.StrOpt('catalog_type',
+ default='baremetal',
+ help="Catalog type of the baremetal provisioning service."),
+]
+
+
# this should never be called outside of this class
class TempestConfigPrivate(object):
"""Provides OpenStack configuration information."""
@@ -721,6 +734,8 @@
register_opt_group(cfg.CONF, service_available_group,
ServiceAvailableGroup)
register_opt_group(cfg.CONF, debug_group, DebugGroup)
+ register_opt_group(cfg.CONF, baremetal_group, BaremetalGroup)
+
self.compute = cfg.CONF.compute
self.compute_feature_enabled = cfg.CONF['compute-feature-enabled']
self.identity = cfg.CONF.identity
@@ -743,6 +758,8 @@
self.scenario = cfg.CONF.scenario
self.service_available = cfg.CONF.service_available
self.debug = cfg.CONF.debug
+ self.baremetal = cfg.CONF.baremetal
+
if not self.compute_admin.username:
self.compute_admin.username = self.identity.admin_username
self.compute_admin.password = self.identity.admin_password
diff --git a/tempest/services/baremetal/__init__.py b/tempest/services/baremetal/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tempest/services/baremetal/__init__.py
diff --git a/tempest/services/baremetal/base.py b/tempest/services/baremetal/base.py
new file mode 100644
index 0000000..3d4fa50
--- /dev/null
+++ b/tempest/services/baremetal/base.py
@@ -0,0 +1,197 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import functools
+import json
+
+import six
+
+from tempest.common import rest_client
+
+
+def handle_errors(f):
+ """A decorator that allows to ignore certain types of errors."""
+
+ @functools.wraps(f)
+ def wrapper(*args, **kwargs):
+ param_name = 'ignore_errors'
+ ignored_errors = kwargs.get(param_name, tuple())
+
+ if param_name in kwargs:
+ del kwargs[param_name]
+
+ try:
+ return f(*args, **kwargs)
+ except ignored_errors:
+ # Silently ignore errors
+ pass
+
+ return wrapper
+
+
+class BaremetalClient(rest_client.RestClient):
+ """
+ Base Tempest REST client for Ironic API.
+
+ """
+
+ def __init__(self, config, username, password, auth_url, tenant_name=None):
+ super(BaremetalClient, self).__init__(config, username, password,
+ auth_url, tenant_name)
+ self.service = self.config.baremetal.catalog_type
+ self.uri_prefix = ''
+
+ def serialize(self, object_type, object_dict):
+ """Serialize an Ironic object."""
+
+ raise NotImplementedError
+
+ def deserialize(self, object_str):
+ """Deserialize an Ironic object."""
+
+ raise NotImplementedError
+
+ def _get_uri(self, resource_name, uuid=None, permanent=False):
+ """
+ Get URI for a specific resource or object.
+
+ :param resource_name: The name of the REST resource, e.g., 'nodes'.
+ :param uuid: The unique identifier of an object in UUID format.
+ :return: Relative URI for the resource or object.
+
+ """
+ prefix = self.uri_prefix if not permanent else ''
+
+ return '{pref}/{res}{uuid}'.format(pref=prefix,
+ res=resource_name,
+ uuid='/%s' % uuid if uuid else '')
+
+ def _make_patch(self, allowed_attributes, **kw):
+ """
+ Create a JSON patch according to RFC 6902.
+
+ :param allowed_attributes: An iterable object that contains a set of
+ allowed attributes for an object.
+ :param **kw: Attributes and new values for them.
+ :return: A JSON path that sets values of the specified attributes to
+ the new ones.
+
+ """
+ def get_change(kw, path='/'):
+ for name, value in six.iteritems(kw):
+ if isinstance(value, dict):
+ for ch in get_change(value, path + '%s/' % name):
+ yield ch
+ else:
+ yield {'path': path + name,
+ 'value': value,
+ 'op': 'replace'}
+
+ patch = [ch for ch in get_change(kw)
+ if ch['path'].lstrip('/') in allowed_attributes]
+
+ return patch
+
+ def _list_request(self, resource, permanent=False):
+ """
+ Get the list of objects of the specified type.
+
+ :param resource: The name of the REST resource, e.g., 'nodes'.
+ :return: A tuple with the server response and deserialized JSON list
+ of objects
+
+ """
+ uri = self._get_uri(resource, permanent=permanent)
+
+ resp, body = self.get(uri, self.headers)
+
+ return resp, self.deserialize(body)
+
+ def _show_request(self, resource, uuid, permanent=False):
+ """
+ Gets a specific object of the specified type.
+
+ :param uuid: Unique identifier of the object in UUID format.
+ :return: Serialized object as a dictionary.
+
+ """
+ uri = self._get_uri(resource, uuid=uuid, permanent=permanent)
+ resp, body = self.get(uri, self.headers)
+
+ return resp, self.deserialize(body)
+
+ def _create_request(self, resource, object_type, object_dict):
+ """
+ Create an object of the specified type.
+
+ :param resource: The name of the REST resource, e.g., 'nodes'.
+ :param object_dict: A Python dict that represents an object of the
+ specified type.
+ :return: A tuple with the server response and the deserialized created
+ object.
+
+ """
+ body = self.serialize(object_type, object_dict)
+ uri = self._get_uri(resource)
+
+ resp, body = self.post(uri, headers=self.headers, body=body)
+
+ return resp, self.deserialize(body)
+
+ def _delete_request(self, resource, uuid):
+ """
+ Delete specified object.
+
+ :param resource: The name of the REST resource, e.g., 'nodes'.
+ :param uuid: The unique identifier of an object in UUID format.
+ :return: A tuple with the server response and the response body.
+
+ """
+ uri = self._get_uri(resource, uuid)
+
+ resp, body = self.delete(uri, self.headers)
+ return resp, body
+
+ def _patch_request(self, resource, uuid, patch_object):
+ """
+ Update specified object with JSON-patch.
+
+ :param resource: The name of the REST resource, e.g., 'nodes'.
+ :param uuid: The unique identifier of an object in UUID format.
+ :return: A tuple with the server response and the serialized patched
+ object.
+
+ """
+ uri = self._get_uri(resource, uuid)
+ patch_body = json.dumps(patch_object)
+
+ resp, body = self.patch(uri, headers=self.headers, body=patch_body)
+ return resp, self.deserialize(body)
+
+ @handle_errors
+ def get_api_description(self):
+ """Retrieves all versions of the Ironic API."""
+
+ return self._list_request('', permanent=True)
+
+ @handle_errors
+ def get_version_description(self, version='v1'):
+ """
+ Retrieves the desctription of the API.
+
+ :param version: The version of the API. Default: 'v1'.
+ :return: Serialized description of API resources.
+
+ """
+ return self._list_request(version, permanent=True)
diff --git a/tempest/services/baremetal/v1/__init__.py b/tempest/services/baremetal/v1/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tempest/services/baremetal/v1/__init__.py
diff --git a/tempest/services/baremetal/v1/base_v1.py b/tempest/services/baremetal/v1/base_v1.py
new file mode 100644
index 0000000..5fdf036
--- /dev/null
+++ b/tempest/services/baremetal/v1/base_v1.py
@@ -0,0 +1,209 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.services.baremetal import base
+
+
+class BaremetalClientV1(base.BaremetalClient):
+ """
+ Base Tempest REST client for Ironic API v1.
+
+ Specific implementations must implement serialize and deserialize
+ methods in order to send requests to Ironic.
+
+ """
+ def __init__(self, config, username, password, auth_url, tenant_name=None):
+ super(BaremetalClientV1, self).__init__(config, username, password,
+ auth_url, tenant_name)
+ self.version = '1'
+ self.uri_prefix = 'v%s' % self.version
+
+ @base.handle_errors
+ def list_nodes(self):
+ """List all existing nodes."""
+ return self._list_request('nodes')
+
+ @base.handle_errors
+ def list_chassis(self):
+ """List all existing chassis."""
+ return self._list_request('chassis')
+
+ @base.handle_errors
+ def list_ports(self):
+ """List all existing ports."""
+ return self._list_request('ports')
+
+ @base.handle_errors
+ def show_node(self, uuid):
+ """
+ Gets a specific node.
+
+ :param uuid: Unique identifier of the node in UUID format.
+ :return: Serialized node as a dictionary.
+
+ """
+ return self._show_request('nodes', uuid)
+
+ @base.handle_errors
+ def show_chassis(self, uuid):
+ """
+ Gets a specific chassis.
+
+ :param uuid: Unique identifier of the chassis in UUID format.
+ :return: Serialized chassis as a dictionary.
+
+ """
+ return self._show_request('chassis', uuid)
+
+ @base.handle_errors
+ def show_port(self, uuid):
+ """
+ Gets a specific port.
+
+ :param uuid: Unique identifier of the port in UUID format.
+ :return: Serialized port as a dictionary.
+
+ """
+ return self._show_request('ports', uuid)
+
+ @base.handle_errors
+ def create_node(self, chassis_id, **kwargs):
+ """
+ Create a baremetal node with the specified parameters.
+
+ :param cpu_arch: CPU architecture of the node. Default: x86_64.
+ :param cpu_num: Number of CPUs. Default: 8.
+ :param storage: Disk size. Default: 1024.
+ :param memory: Available RAM. Default: 4096.
+ :param driver: Driver name. Default: "fake"
+ :return: A tuple with the server response and the created node.
+
+ """
+ node = {'chassis_uuid': chassis_id,
+ 'properties': {'cpu_arch': kwargs.get('cpu_arch', 'x86_64'),
+ 'cpu_num': kwargs.get('cpu_num', 8),
+ 'storage': kwargs.get('storage', 1024),
+ 'memory': kwargs.get('memory', 4096)},
+ 'driver': kwargs.get('driver', 'fake')}
+
+ return self._create_request('nodes', 'node', node)
+
+ @base.handle_errors
+ def create_chassis(self, **kwargs):
+ """
+ Create a chassis with the specified parameters.
+
+ :param description: The description of the chassis.
+ Default: test-chassis
+ :return: A tuple with the server response and the created chassis.
+
+ """
+ chassis = {'description': kwargs.get('description', 'test-chassis')}
+
+ return self._create_request('chassis', 'chassis', chassis)
+
+ @base.handle_errors
+ def create_port(self, node_id, **kwargs):
+ """
+ Create a port with the specified parameters.
+
+ :param node_id: The ID of the node which owns the port.
+ :param address: MAC address of the port. Default: 01:23:45:67:89:0A.
+ :return: A tuple with the server response and the created port.
+
+ """
+ port = {'address': kwargs.get('address', '01:23:45:67:89:0A'),
+ 'node_uuid': node_id}
+
+ return self._create_request('ports', 'port', port)
+
+ @base.handle_errors
+ def delete_node(self, uuid):
+ """
+ Deletes a node having the specified UUID.
+
+ :param uuid: The unique identifier of the node.
+ :return: A tuple with the server response and the response body.
+
+ """
+ return self._delete_request('nodes', uuid)
+
+ @base.handle_errors
+ def delete_chassis(self, uuid):
+ """
+ Deletes a chassis having the specified UUID.
+
+ :param uuid: The unique identifier of the chassis.
+ :return: A tuple with the server response and the response body.
+
+ """
+ return self._delete_request('chassis', uuid)
+
+ @base.handle_errors
+ def delete_port(self, uuid):
+ """
+ Deletes a port having the specified UUID.
+
+ :param uuid: The unique identifier of the port.
+ :return: A tuple with the server response and the response body.
+
+ """
+ return self._delete_request('ports', uuid)
+
+ @base.handle_errors
+ def update_node(self, uuid, **kwargs):
+ """
+ Update the specified node.
+
+ :param uuid: The unique identifier of the node.
+ :return: A tuple with the server response and the updated node.
+
+ """
+ node_attributes = ('properties/cpu_arch',
+ 'properties/cpu_num',
+ 'properties/storage',
+ 'properties/memory',
+ 'driver')
+
+ patch = self._make_patch(node_attributes, **kwargs)
+
+ return self._patch_request('nodes', uuid, patch)
+
+ @base.handle_errors
+ def update_chassis(self, uuid, **kwargs):
+ """
+ Update the specified chassis.
+
+ :param uuid: The unique identifier of the chassis.
+ :return: A tuple with the server response and the updated chassis.
+
+ """
+ chassis_attributes = ('description',)
+ patch = self._make_patch(chassis_attributes, **kwargs)
+
+ return self._patch_request('chassis', uuid, patch)
+
+ @base.handle_errors
+ def update_port(self, uuid, **kwargs):
+ """
+ Update the specified port.
+
+ :param uuid: The unique identifier of the port.
+ :return: A tuple with the server response and the updated port.
+
+ """
+ port_attributes = ('address',)
+ patch = self._make_patch(port_attributes, **kwargs)
+
+ return self._patch_request('ports', uuid, patch)
diff --git a/tempest/services/baremetal/v1/client_json.py b/tempest/services/baremetal/v1/client_json.py
new file mode 100644
index 0000000..fa7cd67
--- /dev/null
+++ b/tempest/services/baremetal/v1/client_json.py
@@ -0,0 +1,28 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import json
+
+from tempest.services.baremetal.v1 import base_v1
+
+
+class BaremetalClientJSON(base_v1.BaremetalClientV1):
+ """Tempest REST client for Ironic JSON API v1."""
+
+ def __init__(self, config, username, password, auth_url, tenant_name=None):
+ super(BaremetalClientJSON, self).__init__(config, username, password,
+ auth_url, tenant_name)
+
+ self.serialize = lambda obj_type, obj_body: json.dumps(obj_body)
+ self.deserialize = json.loads
diff --git a/tempest/services/baremetal/v1/client_xml.py b/tempest/services/baremetal/v1/client_xml.py
new file mode 100644
index 0000000..a9b5a77
--- /dev/null
+++ b/tempest/services/baremetal/v1/client_xml.py
@@ -0,0 +1,57 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from tempest.common import rest_client
+from tempest.services.baremetal.v1 import base_v1 as base
+from tempest.services.compute.xml import common as xml
+
+
+class BaremetalClientXML(rest_client.RestClientXML, base.BaremetalClientV1):
+ """Tempest REST client for Ironic XML API v1."""
+
+ def __init__(self, config, username, password, auth_url, tenant_name=None):
+ super(BaremetalClientXML, self).__init__(config, username, password,
+ auth_url, tenant_name)
+
+ self.serialize = self.json_to_xml
+ self.deserialize = xml.xml_to_json
+
+ def json_to_xml(self, object_type, object_dict):
+ """
+ Brainlessly converts a specification of an object to XML string.
+
+ :param object_type: Kind of the object.
+ :param object_dict: Specification of the object attributes as a dict.
+ :return: An XML string that corresponds to the specification.
+
+ """
+ root = xml.Element(object_type)
+
+ for attr_name, value in object_dict:
+ # Handle nested dictionaries
+ if isinstance(value, dict):
+ value = self.json_to_xml(attr_name, value)
+
+ root.append(xml.Element(attr_name, value))
+
+ return str(xml.Document(root))
+
+ def _patch_request(self, resource_name, uuid, patch_object):
+ """Changes Content-Type header to application/json for jsonpatch."""
+
+ self.headers['Content-Type'] = 'application/json'
+ try:
+ super(self)._patch_request(self, resource_name, uuid, patch_object)
+ finally:
+ self.headers['Content-Type'] = 'application/xml'
diff --git a/tempest/services/volume/json/snapshots_client.py b/tempest/services/volume/json/snapshots_client.py
index 9435122..c270de8 100644
--- a/tempest/services/volume/json/snapshots_client.py
+++ b/tempest/services/volume/json/snapshots_client.py
@@ -149,3 +149,40 @@
url = 'snapshots/%s/action' % str(snapshot_id)
resp, body = self.post(url, post_body, self.headers)
return resp, body
+
+ def create_snapshot_metadata(self, snapshot_id, metadata):
+ """Create metadata for the snapshot."""
+ put_body = json.dumps({'metadata': metadata})
+ url = "snapshots/%s/metadata" % str(snapshot_id)
+ resp, body = self.post(url, put_body, self.headers)
+ body = json.loads(body)
+ return resp, body['metadata']
+
+ def get_snapshot_metadata(self, snapshot_id):
+ """Get metadata of the snapshot."""
+ url = "snapshots/%s/metadata" % str(snapshot_id)
+ resp, body = self.get(url, self.headers)
+ body = json.loads(body)
+ return resp, body['metadata']
+
+ def update_snapshot_metadata(self, snapshot_id, metadata):
+ """Update metadata for the snapshot."""
+ put_body = json.dumps({'metadata': metadata})
+ url = "snapshots/%s/metadata" % str(snapshot_id)
+ resp, body = self.put(url, put_body, self.headers)
+ body = json.loads(body)
+ return resp, body['metadata']
+
+ def update_snapshot_metadata_item(self, snapshot_id, id, meta_item):
+ """Update metadata item for the snapshot."""
+ put_body = json.dumps({'meta': meta_item})
+ url = "snapshots/%s/metadata/%s" % (str(snapshot_id), str(id))
+ resp, body = self.put(url, put_body, self.headers)
+ body = json.loads(body)
+ return resp, body['meta']
+
+ def delete_snapshot_metadata_item(self, snapshot_id, id):
+ """Delete metadata item for the snapshot."""
+ url = "snapshots/%s/metadata/%s" % (str(snapshot_id), str(id))
+ resp, body = self.delete(url, self.headers)
+ return resp, body
diff --git a/tempest/services/volume/xml/snapshots_client.py b/tempest/services/volume/xml/snapshots_client.py
index 5d59b07..3a70eab 100644
--- a/tempest/services/volume/xml/snapshots_client.py
+++ b/tempest/services/volume/xml/snapshots_client.py
@@ -22,6 +22,7 @@
from tempest.openstack.common import log as logging
from tempest.services.compute.xml.common import Document
from tempest.services.compute.xml.common import Element
+from tempest.services.compute.xml.common import Text
from tempest.services.compute.xml.common import xml_to_json
from tempest.services.compute.xml.common import XMLNS_11
@@ -170,3 +171,57 @@
if body:
body = xml_to_json(etree.fromstring(body))
return resp, body
+
+ def _metadata_body(self, meta):
+ post_body = Element('metadata')
+ for k, v in meta.items():
+ data = Element('meta', key=k)
+ data.append(Text(v))
+ post_body.append(data)
+ return post_body
+
+ def _parse_key_value(self, node):
+ """Parse <foo key='key'>value</foo> data into {'key': 'value'}."""
+ data = {}
+ for node in node.getchildren():
+ data[node.get('key')] = node.text
+ return data
+
+ def create_snapshot_metadata(self, snapshot_id, metadata):
+ """Create metadata for the snapshot."""
+ post_body = self._metadata_body(metadata)
+ resp, body = self.post('snapshots/%s/metadata' % snapshot_id,
+ str(Document(post_body)),
+ self.headers)
+ body = self._parse_key_value(etree.fromstring(body))
+ return resp, body
+
+ def get_snapshot_metadata(self, snapshot_id):
+ """Get metadata of the snapshot."""
+ url = "snapshots/%s/metadata" % str(snapshot_id)
+ resp, body = self.get(url, self.headers)
+ body = self._parse_key_value(etree.fromstring(body))
+ return resp, body
+
+ def update_snapshot_metadata(self, snapshot_id, metadata):
+ """Update metadata for the snapshot."""
+ put_body = self._metadata_body(metadata)
+ url = "snapshots/%s/metadata" % str(snapshot_id)
+ resp, body = self.put(url, str(Document(put_body)), self.headers)
+ body = self._parse_key_value(etree.fromstring(body))
+ return resp, body
+
+ def update_snapshot_metadata_item(self, snapshot_id, id, meta_item):
+ """Update metadata item for the snapshot."""
+ for k, v in meta_item.items():
+ put_body = Element('meta', key=k)
+ put_body.append(Text(v))
+ url = "snapshots/%s/metadata/%s" % (str(snapshot_id), str(id))
+ resp, body = self.put(url, str(Document(put_body)), self.headers)
+ body = xml_to_json(etree.fromstring(body))
+ return resp, body
+
+ def delete_snapshot_metadata_item(self, snapshot_id, id):
+ """Delete metadata item for the snapshot."""
+ url = "snapshots/%s/metadata/%s" % (str(snapshot_id), str(id))
+ return self.delete(url)