From 415931a6276780b5cc70e058017630a6cd3f5e46 Mon Sep 17 00:00:00 2001 From: Pavel Boldin Date: Sat, 24 Jan 2015 01:42:20 +0200 Subject: [PATCH] Add the context custom_image Add a new context with the name `custom_image'. This context is hidden and acts as the base for the contexts of the virtual machine boot from a custom prepared images. This is used for e.g. preparing an image with benchmark application preinstalled. If there is `admin' user then image is created using the first available user and then makes it public using the `admin' user. If no `admin' user is available then image is created for each user. Co-Authored-By: Tzanetos Balitsaris Implements: blueprint benchmark-vms Change-Id: I63a6a0f0f8f1270014ef8322b8e1998b162eb760 --- rally/benchmark/context/vm/__init__.py | 0 rally/benchmark/context/vm/custom_image.py | 245 ++++++++++++++++++ rally/benchmark/scenarios/nova/utils.py | 4 +- rally/benchmark/scenarios/vm/utils.py | 34 ++- tests/unit/benchmark/context/vm/__init__.py | 0 .../benchmark/context/vm/test_custom_image.py | 227 ++++++++++++++++ .../unit/benchmark/scenarios/vm/test_utils.py | 29 ++- 7 files changed, 522 insertions(+), 17 deletions(-) create mode 100644 rally/benchmark/context/vm/__init__.py create mode 100644 rally/benchmark/context/vm/custom_image.py create mode 100644 tests/unit/benchmark/context/vm/__init__.py create mode 100644 tests/unit/benchmark/context/vm/test_custom_image.py diff --git a/rally/benchmark/context/vm/__init__.py b/rally/benchmark/context/vm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rally/benchmark/context/vm/custom_image.py b/rally/benchmark/context/vm/custom_image.py new file mode 100644 index 0000000000..6e3bbcc7f6 --- /dev/null +++ b/rally/benchmark/context/vm/custom_image.py @@ -0,0 +1,245 @@ +# Copyright 2015: Mirantis Inc. +# 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 abc + +import six + +from rally.benchmark.context import base +from rally.benchmark.scenarios.nova import utils as nova_utils +from rally.benchmark.scenarios.vm import vmtasks +from rally.benchmark import types +from rally.common import broker +from rally.common.i18n import _ +from rally.common import log as logging +from rally.common import utils +from rally import consts +from rally import osclients + +LOG = logging.getLogger(__name__) + + +@six.add_metaclass(abc.ABCMeta) +@base.context(name="custom_image", order=500, hidden=True) +class BaseCustomImageGenerator(base.Context): + """Base class for the contexts providing customized image with. + + Every context class for the specific cusomization must implement the method + `_customize_image` that is able to connect to the server using SSH + and e.g. install applications inside it. + + This is used e.g. to install the benchmark application using SSH + access. + + This base context class provides a way to prepare an image with + custom preinstalled applications. Basically, this code boots a VM, calls + the `_customize_image` and then snapshots the VM disk, removing the VM + afterwards. The image UUID is stored in the user["custom_image"]["id"] + and can be used afterwards by scenario. + """ + + CONFIG_SCHEMA = { + "type": "object", + "$schema": consts.JSON_SCHEMA, + "properties": { + "image": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "flavor": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "floating_network": { + "type": "string" + }, + "internal_network": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "userdata": { + "type": "string" + }, + "workers": { + "type": "integer", + "minimum": 1, + } + }, + "required": ["image", "flavor"], + "additionalProperties": False + } + + DEFAULT_CONFIG = { + "username": "root", + "port": 22, + "workers": 1 + } + + @utils.log_task_wrapper(LOG.info, _("Enter context: `custom_image`")) + def setup(self): + """Creates custom image(s) with preinstalled applications. + + When admin is present creates one public image that is usable + from all the tenants and users. Otherwise create one image + per user and tenant. + """ + + if "admin" in self.context: + # NOTE(pboldin): Create by first user and make it public by + # the admin + user = self.context["users"][0] + tenant = self.context["tenants"][user["tenant_id"]] + + nics = None + if "networks" in tenant: + nics = [{"net-id": tenant["networks"][0]["id"]}] + + custom_image = self.create_one_image(user, nics=nics) + self.make_image_public(custom_image) + + for tenant in self.context["tenants"].values(): + tenant["custom_image"] = custom_image + else: + def publish(queue): + users = self.context.get("users", []) + for user, tenant_id in utils.iterate_per_tenants(users): + queue.append((user, tenant_id)) + + def consume(cache, args): + user, tenant_id = args + tenant = self.context["tenants"][tenant_id] + tenant["custom_image"] = self.create_one_image(user) + + broker.run(publish, consume, self.config["workers"]) + + def create_one_image(self, user, **kwargs): + """Create one image for the user.""" + + clients = osclients.Clients(user["endpoint"]) + + image_id = types.ImageResourceType.transform( + clients=clients, resource_config=self.config["image"]) + flavor_id = types.FlavorResourceType.transform( + clients=clients, resource_config=self.config["flavor"]) + + vm_scenario = vmtasks.VMTasks(self.context, clients=clients) + + server, fip = vm_scenario._boot_server_with_fip( + name=vm_scenario._generate_random_name("rally_ctx_custom_image_"), + image=image_id, flavor=flavor_id, + floating_network=self.config.get("floating_network"), + userdata=self.config.get("userdata"), + key_name=user["keypair"]["name"], + security_groups=[user["secgroup"]["name"]], + **kwargs) + + LOG.debug("Installing benchmark on %r %s", server, fip["ip"]) + self.customize_image(server, fip, user) + + LOG.debug("Stopping server %r", server) + vm_scenario._stop_server(server) + + LOG.debug("Creating snapshot for %r", server) + custom_image = vm_scenario._create_image(server).to_dict() + + vm_scenario._delete_server_with_fip(server, fip) + + return custom_image + + def make_image_public(self, custom_image): + """Make the image available publicly.""" + + admin_clients = osclients.Clients(self.context["admin"]["endpoint"]) + + LOG.debug("Making image %r public", custom_image["id"]) + admin_clients.glance().images.get( + custom_image["id"]).update(is_public=True) + + @utils.log_task_wrapper(LOG.info, _("Exit context: `custom_image`")) + def cleanup(self): + """Delete created custom image(s).""" + + if "admin" in self.context: + user = self.context["users"][0] + tenant = self.context["tenants"][user["tenant_id"]] + if "custom_image" in tenant: + self.delete_one_image(user, tenant["custom_image"]) + tenant.pop("custom_image") + else: + def publish(queue): + users = self.context.get("users", []) + for user, tenant_id in utils.iterate_per_tenants(users): + queue.append((user, tenant_id)) + + def consume(cache, args): + user, tenant_id = args + tenant = self.context["tenants"][tenant_id] + if "custom_image" in tenant: + self.delete_one_image(user, tenant["custom_image"]) + tenant.pop("custom_image") + + broker.run(publish, consume, self.config["workers"]) + + def delete_one_image(self, user, custom_image): + """Delete the image created for the user and tenant.""" + + clients = osclients.Clients(user["endpoint"]) + + nova_scenario = nova_utils.NovaScenario( + context=self.context, clients=clients) + + with logging.ExceptionLogger( + LOG, _("Unable to delete image %s") % custom_image["id"]): + + custom_image = nova_scenario.clients("nova").images.get( + custom_image["id"]) + nova_scenario._delete_image(custom_image) + + @utils.log_task_wrapper(LOG.info, + _("Custom image context: customizing")) + def customize_image(self, server, fip, user): + return self._customize_image(server, fip, user) + + @abc.abstractmethod + def _customize_image(self, server, fip, user): + """Override this method with one that customizes image. + + Basically, code can simply call `VMScenario._run_command` function + specifying an installation script and interpreter. This script will + be then executed using SSH. + + :param server: nova.Server instance + :param fip: dict with Floating IP details + :param user: user who started a VM instance. Used to extract keypair + """ + pass diff --git a/rally/benchmark/scenarios/nova/utils.py b/rally/benchmark/scenarios/nova/utils.py index ed505da299..43cbf3c281 100644 --- a/rally/benchmark/scenarios/nova/utils.py +++ b/rally/benchmark/scenarios/nova/utils.py @@ -104,8 +104,8 @@ class NovaScenario(base.Scenario): kwargs["security_groups"].append(secgroup["name"]) if auto_assign_nic and not kwargs.get("nics", False): - nets = [net["id"] - for net in self.context["tenant"].get("networks", [])] + nets = [net["id"] for net in + self.context.get("tenant", {}).get("networks", [])] if nets: # NOTE(amaretskiy): Balance servers among networks: # divmod(iteration % tenants_num, nets_num)[1] diff --git a/rally/benchmark/scenarios/vm/utils.py b/rally/benchmark/scenarios/vm/utils.py index c95b59b00d..df4b786187 100644 --- a/rally/benchmark/scenarios/vm/utils.py +++ b/rally/benchmark/scenarios/vm/utils.py @@ -17,12 +17,15 @@ import subprocess import sys import netaddr +import six from rally.benchmark.scenarios import base from rally.benchmark import utils as bench_utils from rally.benchmark.wrappers import network as network_wrapper +from rally.common.i18n import _ from rally.common import log as logging from rally.common import sshutils +from rally import exceptions LOG = logging.getLogger(__name__) @@ -33,15 +36,29 @@ class VMScenario(base.Scenario): VM scenarios are scenarios executed inside some launched VM instance. """ - @base.atomic_action_timer("vm.run_command") - def _run_action(self, ssh, interpreter, script): + @base.atomic_action_timer("vm.run_command_over_ssh") + def _run_command_over_ssh(self, ssh, interpreter, script): """Run command inside an instance. This is a separate function so that only script execution is timed. + :param ssh: A SSHClient instance. + :param interpreter: The interpreter that will be used to execute + the script. + :param script: Path to the script file or its content in a StringIO. + :returns: tuple (exit_status, stdout, stderr) """ - return ssh.execute(interpreter, stdin=open(script, "rb")) + if isinstance(script, six.string_types): + stdin = open(script, "rb") + elif isinstance(script, six.moves.StringIO): + stdin = script + else: + raise exceptions.ScriptError( + "Either file path or StringIO expected, given %s" % + type(script).__name__) + + return ssh.execute(interpreter, stdin=stdin) def _get_netwrap(self): if not hasattr(self, "_netwrap"): @@ -106,20 +123,19 @@ class VMScenario(base.Scenario): ) def _run_command(self, server_ip, port, username, password, - interpreter, script): + interpreter, script, pkey=None): """Run command via SSH on server. Create SSH connection for server, wait for server to become available (there is a delay between server being set to ACTIVE - and sshd being available). Then call run_action to actually + and sshd being available). Then call run_command_over_ssh to actually execute the command. """ + pkey = pkey if pkey else self.context["user"]["keypair"]["private"] ssh = sshutils.SSH(username, server_ip, port=port, - pkey=self.context["user"]["keypair"]["private"], - password=password) - + pkey=pkey, password=password) self._wait_for_ssh(ssh) - return self._run_action(ssh, interpreter, script) + return self._run_command_over_ssh(ssh, interpreter, script) @staticmethod def _ping_ip_address(host, should_succeed=True): diff --git a/tests/unit/benchmark/context/vm/__init__.py b/tests/unit/benchmark/context/vm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/benchmark/context/vm/test_custom_image.py b/tests/unit/benchmark/context/vm/test_custom_image.py new file mode 100644 index 0000000000..545bec0b65 --- /dev/null +++ b/tests/unit/benchmark/context/vm/test_custom_image.py @@ -0,0 +1,227 @@ +# Copyright 2015: Mirantis Inc. +# 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. + +"""Tests for the Benchmark VM image context.""" + +import mock + +from rally.benchmark.context.vm import custom_image +from tests.unit import test + +BASE = "rally.benchmark.context.vm.custom_image" + + +class TestImageGenerator(custom_image.BaseCustomImageGenerator): + def _customize_image(self, *args): + pass + + +class BaseCustomImageContextVMTestCase(test.TestCase): + + def setUp(self): + super(BaseCustomImageContextVMTestCase, self).setUp() + + self.context = { + "task": mock.MagicMock(), + "config": { + "custom_image": { + "image": {"name": "image"}, + "flavor": {"name": "flavor"}, + "username": "fedora", + "floating_network": "floating", + "port": 1022, + } + }, + "admin": { + "endpoint": "endpoint", + }, + "users": [ + {"tenant_id": "tenant_id0"}, + {"tenant_id": "tenant_id1"}, + {"tenant_id": "tenant_id2"} + ], + "tenants": { + "tenant_id0": {}, + "tenant_id1": {}, + "tenant_id2": {} + } + } + + @mock.patch("%s.vmtasks.VMTasks" % BASE) + @mock.patch("%s.osclients.Clients" % BASE) + @mock.patch("%s.types.ImageResourceType.transform" % BASE, + return_value="image") + @mock.patch("%s.types.FlavorResourceType.transform" % BASE, + return_value="flavor") + def test_create_one_image(self, mock_flavor_transform, + mock_image_transform, mock_osclients, + mock_vmtasks): + fip = {"ip": "foo_ip"} + fake_server = mock.Mock() + + fake_image = mock.MagicMock( + to_dict=mock.MagicMock(return_value={"id": "image"})) + + mock_vm_scenario = mock_vmtasks.return_value = mock.MagicMock( + _create_image=mock.MagicMock(return_value=fake_image), + _boot_server_with_fip=mock.MagicMock( + return_value=(fake_server, fip)), + _generate_random_name=mock.MagicMock(return_value="foo_name"), + ) + + generator_ctx = TestImageGenerator(self.context) + generator_ctx._customize_image = mock.MagicMock() + + user = { + "endpoint": "endpoint", + "keypair": {"name": "keypair_name"}, + "secgroup": {"name": "secgroup_name"} + } + + custom_image = generator_ctx.create_one_image(user, + foo_arg="foo_value") + + mock_flavor_transform.assert_called_once_with( + clients=mock_osclients.return_value, + resource_config={"name": "flavor"}) + mock_image_transform.assert_called_once_with( + clients=mock_osclients.return_value, + resource_config={"name": "image"}) + mock_vmtasks.assert_called_once_with( + self.context, clients=mock_osclients.return_value) + + mock_vm_scenario._boot_server_with_fip.assert_called_once_with( + name="foo_name", image="image", flavor="flavor", + floating_network="floating", key_name="keypair_name", + security_groups=["secgroup_name"], + userdata=None, foo_arg="foo_value") + + mock_vm_scenario._stop_server.assert_called_once_with(fake_server) + + generator_ctx._customize_image.assert_called_once_with( + fake_server, fip, user) + + mock_vm_scenario._create_image.assert_called_once_with(fake_server) + + mock_vm_scenario._delete_server_with_fip.assert_called_once_with( + fake_server, fip) + + self.assertEqual({"id": "image"}, custom_image) + + @mock.patch("%s.osclients.Clients" % BASE) + def test_make_image_public(self, mock_osclients): + fc = mock.MagicMock() + mock_osclients.return_value = fc + + generator_ctx = TestImageGenerator(self.context) + custom_image = {"id": "image"} + + generator_ctx.make_image_public(custom_image=custom_image) + + mock_osclients.assert_called_once_with( + self.context["admin"]["endpoint"]) + + fc.glance.assert_called_once_with() + fc.glance.return_value.images.get.assert_called_once_with("image") + (fc.glance.return_value.images.get. + return_value.update.assert_called_once_with(is_public=True)) + + @mock.patch("%s.nova_utils.NovaScenario" % BASE) + @mock.patch("%s.osclients.Clients" % BASE) + def test_delete_one_image(self, mock_osclients, mock_nova_scenario): + nova_scenario = mock_nova_scenario.return_value = mock.MagicMock() + nova_client = nova_scenario.clients.return_value + nova_client.images.get.return_value = "image_obj" + + generator_ctx = TestImageGenerator(self.context) + + user = {"endpoint": "endpoint", "keypair": {"name": "keypair_name"}} + custom_image = {"id": "image"} + + generator_ctx.delete_one_image(user, custom_image) + + mock_nova_scenario.assert_called_once_with( + context=self.context, clients=mock_osclients.return_value) + + nova_scenario.clients.assert_called_once_with("nova") + nova_client.images.get.assert_called_once_with("image") + nova_scenario._delete_image.assert_called_once_with("image_obj") + + def test_setup_admin(self): + self.context["tenants"]["tenant_id0"]["networks"] = [ + {"id": "network_id"}] + + generator_ctx = TestImageGenerator(self.context) + + generator_ctx.create_one_image = mock.Mock( + return_value="custom_image") + generator_ctx.make_image_public = mock.Mock() + + generator_ctx.setup() + + generator_ctx.create_one_image.assert_called_once_with( + self.context["users"][0], nics=[{"net-id": "network_id"}]) + generator_ctx.make_image_public.assert_called_once_with( + "custom_image") + + def test_cleanup_admin(self): + tenant = self.context["tenants"]["tenant_id0"] + custom_image = tenant["custom_image"] = {"id": "image"} + + generator_ctx = TestImageGenerator(self.context) + + generator_ctx.delete_one_image = mock.Mock() + + generator_ctx.cleanup() + + generator_ctx.delete_one_image.assert_called_once_with( + self.context["users"][0], custom_image) + + def test_setup(self): + self.context.pop("admin") + + generator_ctx = TestImageGenerator(self.context) + + generator_ctx.create_one_image = mock.Mock( + side_effect=["custom_image0", "custom_image1", "custom_image2"]) + + generator_ctx.setup() + + self.assertEqual( + [mock.call(user) for user in self.context["users"]], + generator_ctx.create_one_image.mock_calls) + + for i in range(3): + self.assertEqual( + "custom_image%d" % i, + self.context["tenants"]["tenant_id%d" % i]["custom_image"] + ) + + def test_cleanup(self): + self.context.pop("admin") + + for i in range(3): + self.context["tenants"]["tenant_id%d" % i]["custom_image"] = { + "id": "custom_image%d" % i} + + generator_ctx = TestImageGenerator(self.context) + generator_ctx.delete_one_image = mock.Mock() + + generator_ctx.cleanup() + + self.assertEqual( + [mock.call(self.context["users"][i], + {"id": "custom_image%d" % i}) for i in range(3)], + generator_ctx.delete_one_image.mock_calls) diff --git a/tests/unit/benchmark/scenarios/vm/test_utils.py b/tests/unit/benchmark/scenarios/vm/test_utils.py index f2c4b14b0f..aa8beb8f0f 100644 --- a/tests/unit/benchmark/scenarios/vm/test_utils.py +++ b/tests/unit/benchmark/scenarios/vm/test_utils.py @@ -16,10 +16,13 @@ import subprocess + import mock from oslotest import mockpatch +import six from rally.benchmark.scenarios.vm import utils +from rally import exceptions from tests.unit import test @@ -36,13 +39,27 @@ class VMScenarioTestCase(test.TestCase): @mock.patch("%s.open" % VMTASKS_UTILS, side_effect=mock.mock_open(), create=True) - def test__run_action(self, mock_open): + def test__run_command_over_ssh(self, mock_open): mock_ssh = mock.MagicMock() vm_scenario = utils.VMScenario() - vm_scenario._run_action(mock_ssh, "interpreter", "script") + vm_scenario._run_command_over_ssh(mock_ssh, "interpreter", "script") mock_ssh.execute.assert_called_once_with("interpreter", stdin=mock_open.side_effect()) + def test__run_command_over_ssh_stringio(self): + mock_ssh = mock.MagicMock() + vm_scenario = utils.VMScenario() + script = six.moves.StringIO("script") + vm_scenario._run_command_over_ssh(mock_ssh, "interpreter", script) + mock_ssh.execute.assert_called_once_with("interpreter", + stdin=script) + + def test__run_command_over_ssh_fails(self): + vm_scenario = utils.VMScenario() + self.assertRaises(exceptions.ScriptError, + vm_scenario._run_command_over_ssh, + None, "interpreter", 10) + def test__wait_for_ssh(self): ssh = mock.MagicMock() vm_scenario = utils.VMScenario() @@ -58,9 +75,9 @@ class VMScenarioTestCase(test.TestCase): is_ready=mock__ping, timeout=120) - @mock.patch(VMTASKS_UTILS + ".VMScenario._run_action") + @mock.patch(VMTASKS_UTILS + ".VMScenario._run_command_over_ssh") @mock.patch("rally.common.sshutils.SSH") - def test__run_command(self, mock_ssh_class, mock_run_action): + def test__run_command(self, mock_ssh_class, mock_run_command_over_ssh): mock_ssh_instance = mock.MagicMock() mock_ssh_class.return_value = mock_ssh_instance @@ -73,8 +90,8 @@ class VMScenarioTestCase(test.TestCase): pkey="ssh", password="password") mock_ssh_instance.wait.assert_called_once_with() - mock_run_action.assert_called_once_with(mock_ssh_instance, - "int", "script") + mock_run_command_over_ssh.assert_called_once_with( + mock_ssh_instance, "int", "script") @mock.patch(VMTASKS_UTILS + ".sys") @mock.patch("subprocess.Popen")