diff options
author | Zuul <zuul@review.opendev.org> | 2019-08-15 23:39:50 +0000 |
---|---|---|
committer | Gerrit Code Review <review@openstack.org> | 2019-08-15 23:39:50 +0000 |
commit | 35485eacfbd444ddbb7f2d1e8637bcecb295b485 (patch) | |
tree | 8ec348bb79d214d65c55daf72b989a1bad49b908 | |
parent | 872a823d9a02f31e266882bfb499673c51fb4075 (diff) | |
parent | 62f6a0a1bc6c4b24621e1c2e927177f99501bef3 (diff) | |
download | nova-35485eacfbd444ddbb7f2d1e8637bcecb295b485.tar.gz |
Merge "API microversion 2.76: Add 'power-update' external event"
23 files changed, 635 insertions, 20 deletions
diff --git a/api-ref/source/os-server-external-events.inc b/api-ref/source/os-server-external-events.inc index b31c38116f..f04c98839f 100644 --- a/api-ref/source/os-server-external-events.inc +++ b/api-ref/source/os-server-external-events.inc @@ -7,11 +7,11 @@ .. warning:: This is an ``admin`` level service API only designed to be used by other OpenStack services. The point of this API is to coordinate - between Nova and Neutron, Nova and Cinder (and potentially future - services) on activities they both need to be involved in, + between Nova and Neutron, Nova and Cinder, Nova and Ironic (and potentially + future services) on activities they both need to be involved in, such as network hotplugging. - Unless you are writing Neutron or Cinder code you **should not** + Unless you are writing Neutron, Cinder or Ironic code you **should not** be using this API. Creates one or more external events. The API dispatches each event to a diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index f2df3193ff..34fb3d1679 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -2587,9 +2587,15 @@ event_hostId: type: string event_name: description: | - The event name. A valid value is ``network-changed``, ``network-vif-plugged``, - ``network-vif-unplugged``, ``network-vif-deleted``, or ``volume-extended``. - The event name ``volume-extended`` is added since microversion ``2.51``. + The event name. A valid value is: + + - ``network-changed`` + - ``network-vif-plugged`` + - ``network-vif-unplugged`` + - ``network-vif-deleted`` + - ``volume-extended`` (since microversion ``2.51``) + - ``power-update`` (since microversion ``2.76``) + in: body required: true type: string @@ -2623,7 +2629,13 @@ event_status: type: string event_tag: description: | - A string value that identifies the event. + A string value that identifies the event. Certain types of events require + specific tags: + + - For the ``power-update`` event the tag must be either be ``POWER_ON`` + or ``POWER_OFF``. + - For the ``volume-extended`` event the tag must be the volume id. + in: body required: false type: string diff --git a/doc/api_samples/versions/v21-version-get-resp.json b/doc/api_samples/versions/v21-version-get-resp.json index ddcb8b65d8..b5c1ad05e1 100644 --- a/doc/api_samples/versions/v21-version-get-resp.json +++ b/doc/api_samples/versions/v21-version-get-resp.json @@ -19,7 +19,7 @@ } ], "status": "CURRENT", - "version": "2.75", + "version": "2.76", "min_version": "2.1", "updated": "2013-07-23T11:33:21Z" } diff --git a/doc/api_samples/versions/versions-get-resp.json b/doc/api_samples/versions/versions-get-resp.json index 2df2d3c69a..f7b96be8f2 100644 --- a/doc/api_samples/versions/versions-get-resp.json +++ b/doc/api_samples/versions/versions-get-resp.json @@ -22,7 +22,7 @@ } ], "status": "CURRENT", - "version": "2.75", + "version": "2.76", "min_version": "2.1", "updated": "2013-07-23T11:33:21Z" } diff --git a/nova/api/openstack/api_version_request.py b/nova/api/openstack/api_version_request.py index b3ddea6f5d..a41c029f49 100644 --- a/nova/api/openstack/api_version_request.py +++ b/nova/api/openstack/api_version_request.py @@ -196,6 +196,11 @@ REST_API_VERSION_HISTORY = """REST API Version History: string to 0 (integer) in flavor APIs. - Return ``servers`` field always in the response of GET hypervisors API even there are no servers on hypervisor. + * 2.76 - Adds ``power-update`` event to ``os-server-external-events`` API. + The changes to the power state of an instance caused by this event + can be viewed through + ``GET /servers/{server_id}/os-instance-actions`` and + ``GET /servers/{server_id}/os-instance-actions/{request_id}``. """ # The minimum and maximum versions of the API supported @@ -204,7 +209,7 @@ REST_API_VERSION_HISTORY = """REST API Version History: # Note(cyeoh): This only applies for the v2.1 API once microversions # support is fully merged. It does not affect the V2 API. _MIN_API_VERSION = "2.1" -_MAX_API_VERSION = "2.75" +_MAX_API_VERSION = "2.76" DEFAULT_API_VERSION = _MIN_API_VERSION # Almost all proxy APIs which are related to network, images and baremetal diff --git a/nova/api/openstack/common.py b/nova/api/openstack/common.py index 36f6ed739a..c2bab9da0d 100644 --- a/nova/api/openstack/common.py +++ b/nova/api/openstack/common.py @@ -41,6 +41,9 @@ LOG = logging.getLogger(__name__) QUOTAS = quota.QUOTAS +POWER_ON = 'POWER_ON' +POWER_OFF = 'POWER_OFF' + _STATE_MAP = { vm_states.ACTIVE: { 'default': 'ACTIVE', diff --git a/nova/api/openstack/compute/rest_api_version_history.rst b/nova/api/openstack/compute/rest_api_version_history.rst index ace773fdbc..233abb0d16 100644 --- a/nova/api/openstack/compute/rest_api_version_history.rst +++ b/nova/api/openstack/compute/rest_api_version_history.rst @@ -974,3 +974,11 @@ Multiple API cleanups is done in API microversion 2.75: * Return ``servers`` field always in the response of GET hypervisors API even there are no servers on hypervisor. + +2.76 +---- + +Adds ``power-update`` event name to ``os-server-external-events`` API. The +changes to the power state of an instance caused by this event can be viewed +through ``GET /servers/{server_id}/os-instance-actions`` and +``GET /servers/{server_id}/os-instance-actions/{request_id}``. diff --git a/nova/api/openstack/compute/schemas/server_external_events.py b/nova/api/openstack/compute/schemas/server_external_events.py index 38435e0dc5..ff02a08430 100644 --- a/nova/api/openstack/compute/schemas/server_external_events.py +++ b/nova/api/openstack/compute/schemas/server_external_events.py @@ -55,3 +55,7 @@ create = { create_v251 = copy.deepcopy(create) name = create_v251['properties']['events']['items']['properties']['name'] name['enum'].append('volume-extended') + +create_v276 = copy.deepcopy(create_v251) +name = create_v276['properties']['events']['items']['properties']['name'] +name['enum'].append('power-update') diff --git a/nova/api/openstack/compute/server_external_events.py b/nova/api/openstack/compute/server_external_events.py index 1b05556754..fdea25db8c 100644 --- a/nova/api/openstack/compute/server_external_events.py +++ b/nova/api/openstack/compute/server_external_events.py @@ -28,6 +28,9 @@ from nova.policies import server_external_events as see_policies LOG = logging.getLogger(__name__) +TAG_REQUIRED = ('volume-extended', 'power-update') + + class ServerExternalEventsController(wsgi.Controller): def __init__(self): @@ -36,7 +39,7 @@ class ServerExternalEventsController(wsgi.Controller): @staticmethod def _is_event_tag_present_when_required(event): - if event.name == 'volume-extended' and event.tag is None: + if event.name in TAG_REQUIRED and event.tag is None: return False return True @@ -65,7 +68,8 @@ class ServerExternalEventsController(wsgi.Controller): @wsgi.expected_errors((403, 404)) @wsgi.response(200) @validation.schema(server_external_events.create, '2.0', '2.50') - @validation.schema(server_external_events.create_v251, '2.51') + @validation.schema(server_external_events.create_v251, '2.51', '2.75') + @validation.schema(server_external_events.create_v276, '2.76') def create(self, req, body): """Creates a new instance event.""" context = req.environ['nova.context'] diff --git a/nova/compute/api.py b/nova/compute/api.py index ceff30e598..bac9546021 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -68,6 +68,7 @@ from nova.network.security_group import security_group_base from nova import objects from nova.objects import base as obj_base from nova.objects import block_device as block_device_obj +from nova.objects import external_event as external_event_obj from nova.objects import fields as fields_obj from nova.objects import keypair as keypair_obj from nova.objects import quotas as quotas_obj @@ -4711,6 +4712,22 @@ class API(base.Base): objects.InstanceAction.action_start( cell_context, event.instance_uuid, instance_actions.EXTEND_VOLUME, want_result=False) + elif event.name == 'power-update': + host = hosts_by_instance[event.instance_uuid][0] + cell_context = cell_contexts_by_host[host] + if event.tag == external_event_obj.POWER_ON: + inst_action = instance_actions.START + elif event.tag == external_event_obj.POWER_OFF: + inst_action = instance_actions.STOP + else: + LOG.warning("Invalid power state %s. Cannot process " + "the event %s. Skipping it.", event.tag, + event) + continue + objects.InstanceAction.action_start( + cell_context, event.instance_uuid, inst_action, + want_result=False) + for host in hosts_by_instance[event.instance_uuid]: events_by_host[host].append(event) diff --git a/nova/compute/manager.py b/nova/compute/manager.py index 2b05ab503c..f5a3908c41 100644 --- a/nova/compute/manager.py +++ b/nova/compute/manager.py @@ -83,6 +83,7 @@ from nova.network import model as network_model from nova.network.security_group import openstack_driver from nova import objects from nova.objects import base as obj_base +from nova.objects import external_event as external_event_obj from nova.objects import fields from nova.objects import instance as obj_instance from nova.objects import migrate_data as migrate_data_obj @@ -8652,6 +8653,106 @@ class ComputeManager(manager.Manager): instance=instance) raise + @staticmethod + def _is_state_valid_for_power_update_event(instance, target_power_state): + """Check if the current state of the instance allows it to be + a candidate for the power-update event. + + :param instance: The nova instance object. + :param target_power_state: The desired target power state; this should + either be "POWER_ON" or "POWER_OFF". + :returns Boolean: True if the instance can be subjected to the + power-update event. + """ + if ((target_power_state == external_event_obj.POWER_ON and + instance.task_state is None and + instance.vm_state == vm_states.STOPPED and + instance.power_state == power_state.SHUTDOWN) or + (target_power_state == external_event_obj.POWER_OFF and + instance.task_state is None and + instance.vm_state == vm_states.ACTIVE and + instance.power_state == power_state.RUNNING)): + return True + return False + + @wrap_exception() + @reverts_task_state + @wrap_instance_event(prefix='compute') + @wrap_instance_fault + def power_update(self, context, instance, target_power_state): + """Power update of an instance prompted by an external event. + :param context: The API request context. + :param instance: The nova instance object. + :param target_power_state: The desired target power state; + this should either be "POWER_ON" or + "POWER_OFF". + """ + + @utils.synchronized(instance.uuid) + def do_power_update(): + LOG.debug('Handling power-update event with target_power_state %s ' + 'for instance', target_power_state, instance=instance) + if not self._is_state_valid_for_power_update_event( + instance, target_power_state): + pow_state = fields.InstancePowerState.from_index( + instance.power_state) + LOG.info('The power-update %(tag)s event for instance ' + '%(uuid)s is a no-op since the instance is in ' + 'vm_state %(vm_state)s, task_state ' + '%(task_state)s and power_state ' + '%(power_state)s.', + {'tag': target_power_state, 'uuid': instance.uuid, + 'vm_state': instance.vm_state, + 'task_state': instance.task_state, + 'power_state': pow_state}) + return + LOG.debug("Trying to %s instance", + target_power_state, instance=instance) + if target_power_state == external_event_obj.POWER_ON: + action = fields.NotificationAction.POWER_ON + notification_name = "power_on." + instance.task_state = task_states.POWERING_ON + else: + # It's POWER_OFF + action = fields.NotificationAction.POWER_OFF + notification_name = "power_off." + instance.task_state = task_states.POWERING_OFF + instance.progress = 0 + + try: + # Note that the task_state is set here rather than the API + # because this is a best effort operation and deferring + # updating the task_state until we get to the compute service + # avoids error handling in the API and needing to account for + # older compute services during rolling upgrades from Stein. + # If we lose a race, UnexpectedTaskStateError is handled + # below. + instance.save(expected_task_state=[None]) + self._notify_about_instance_usage(context, instance, + notification_name + "start") + compute_utils.notify_about_instance_action(context, instance, + self.host, action=action, + phase=fields.NotificationPhase.START) + # UnexpectedTaskStateError raised from the driver will be + # handled below and not result in a fault, error notification + # or failure of the instance action. Other driver errors like + # NotImplementedError will be record a fault, send an error + # notification and mark the instance action as failed. + self.driver.power_update_event(instance, target_power_state) + self._notify_about_instance_usage(context, instance, + notification_name + "end") + compute_utils.notify_about_instance_action(context, instance, + self.host, action=action, + phase=fields.NotificationPhase.END) + except exception.UnexpectedTaskStateError as e: + # Handling the power-update event is best effort and if we lost + # a race with some other action happening to the instance we + # just log it and return rather than fail the action. + LOG.info("The power-update event was possibly preempted: %s ", + e.format_message(), instance=instance) + return + do_power_update() + @wrap_exception() def external_instance_event(self, context, instances, events): # NOTE(danms): Some event types are handled by the manager, such @@ -8687,6 +8788,8 @@ class ComputeManager(manager.Manager): instance=instance) elif event.name == 'volume-extended': self.extend_volume(context, instance, event.tag) + elif event.name == 'power-update': + self.power_update(context, instance, event.tag) else: self._process_instance_event(instance, event) diff --git a/nova/objects/external_event.py b/nova/objects/external_event.py index cfcc01c027..e2c7e95b11 100644 --- a/nova/objects/external_event.py +++ b/nova/objects/external_event.py @@ -26,10 +26,17 @@ EVENT_NAMES = [ # Volume was extended for this instance, tag is volume_id 'volume-extended', + + # Power state has changed for this instance + 'power-update', ] EVENT_STATUSES = ['failed', 'completed', 'in-progress'] +# Possible tag values for the power-update event. +POWER_ON = 'POWER_ON' +POWER_OFF = 'POWER_OFF' + @obj_base.NovaObjectRegistry.register class InstanceExternalEvent(obj_base.NovaObject): @@ -37,7 +44,8 @@ class InstanceExternalEvent(obj_base.NovaObject): # Supports network-changed and vif-plugged # Version 1.1: adds network-vif-deleted event # Version 1.2: adds volume-extended event - VERSION = '1.2' + # Version 1.3: adds power-update event + VERSION = '1.3' fields = { 'instance_uuid': fields.UUIDField(), diff --git a/nova/tests/functional/api/client.py b/nova/tests/functional/api/client.py index 3609d09550..03221d9721 100644 --- a/nova/tests/functional/api/client.py +++ b/nova/tests/functional/api/client.py @@ -421,6 +421,10 @@ class TestOpenStackClient(object): def delete_server_group(self, group_id): self.api_delete('/os-server-groups/%s' % group_id) + def create_server_external_events(self, events): + body = {'events': events} + return self.api_post('/os-server-external-events', body).body['events'] + def get_instance_actions(self, server_id): return self.api_get('/servers/%s/os-instance-actions' % (server_id)).body['instanceActions'] diff --git a/nova/tests/functional/test_server_external_events.py b/nova/tests/functional/test_server_external_events.py new file mode 100644 index 0000000000..0e25b6f10e --- /dev/null +++ b/nova/tests/functional/test_server_external_events.py @@ -0,0 +1,104 @@ +# 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 nova.compute import instance_actions +from nova.compute import power_state +from nova.compute import vm_states +from nova.tests.functional import integrated_helpers +from nova.tests.unit import fake_notifier + + +class ServerExternalEventsTestV276( + integrated_helpers.ProviderUsageBaseTestCase): + microversion = '2.76' + compute_driver = 'fake.PowerUpdateFakeDriver' + + def setUp(self): + super(ServerExternalEventsTestV276, self).setUp() + self.compute = self.start_service('compute', host='compute') + + flavors = self.api.get_flavors() + server_req = self._build_minimal_create_server_request( + self.api, "some-server", flavor_id=flavors[0]["id"], + image_uuid="155d900f-4e14-4e4c-a73d-069cbf4541e6", + networks='none') + created_server = self.api.post_server({'server': server_req}) + self.server = self._wait_for_state_change( + self.api, created_server, 'ACTIVE') + self.power_off = {'name': 'power-update', + 'tag': 'POWER_OFF', + 'server_uuid': self.server["id"]} + self.power_on = {'name': 'power-update', + 'tag': 'POWER_ON', + 'server_uuid': self.server["id"]} + + def test_server_power_update(self): + # This test checks the functionality of handling the "power-update" + # external events. + self.assertEqual( + power_state.RUNNING, self.server['OS-EXT-STS:power_state']) + self.api.create_server_external_events(events=[self.power_off]) + expected_params = {'OS-EXT-STS:task_state': None, + 'OS-EXT-STS:vm_state': vm_states.STOPPED, + 'OS-EXT-STS:power_state': power_state.SHUTDOWN} + server = self._wait_for_server_parameter(self.api, self.server, + expected_params) + msg = ' with target power state POWER_OFF.' + self.assertIn(msg, self.stdlog.logger.output) + # Test if this is logged in the instance action list. + actions = self.api.get_instance_actions(server['id']) + self.assertEqual(2, len(actions)) + acts = {action['action']: action for action in actions} + self.assertEqual(['create', 'stop'], sorted(acts)) + stop_action = acts[instance_actions.STOP] + detail = self.api.api_get( + '/servers/%s/os-instance-actions/%s' % ( + server['id'], stop_action['request_id']) + ).body['instanceAction'] + events_by_name = {event['event']: event for event in detail['events']} + self.assertEqual(1, len(detail['events']), detail) + self.assertIn('compute_power_update', events_by_name) + self.assertEqual('Success', detail['events'][0]['result']) + # Test if notifications were emitted. + fake_notifier.wait_for_versioned_notifications( + 'instance.power_off.start') + fake_notifier.wait_for_versioned_notifications( + 'instance.power_off.end') + + # Checking POWER_ON + self.api.create_server_external_events(events=[self.power_on]) + expected_params = {'OS-EXT-STS:task_state': None, + 'OS-EXT-STS:vm_state': vm_states.ACTIVE, + 'OS-EXT-STS:power_state': power_state.RUNNING} + server = self._wait_for_server_parameter(self.api, self.server, + expected_params) + msg = ' with target power state POWER_ON.' + self.assertIn(msg, self.stdlog.logger.output) + # Test if this is logged in the instance action list. + actions = self.api.get_instance_actions(server['id']) + self.assertEqual(3, len(actions)) + acts = {action['action']: action for action in actions} + self.assertEqual(['create', 'start', 'stop'], sorted(acts)) + start_action = acts[instance_actions.START] + detail = self.api.api_get( + '/servers/%s/os-instance-actions/%s' % ( + server['id'], start_action['request_id']) + ).body['instanceAction'] + events_by_name = {event['event']: event for event in detail['events']} + self.assertEqual(1, len(detail['events']), detail) + self.assertIn('compute_power_update', events_by_name) + self.assertEqual('Success', detail['events'][0]['result']) + # Test if notifications were emitted. + fake_notifier.wait_for_versioned_notifications( + 'instance.power_on.start') + fake_notifier.wait_for_versioned_notifications( + 'instance.power_on.end') diff --git a/nova/tests/unit/api/openstack/compute/test_server_external_events.py b/nova/tests/unit/api/openstack/compute/test_server_external_events.py index 94f6b5c3a2..99695dd882 100644 --- a/nova/tests/unit/api/openstack/compute/test_server_external_events.py +++ b/nova/tests/unit/api/openstack/compute/test_server_external_events.py @@ -12,8 +12,10 @@ # License for the specific language governing permissions and limitations # under the License. +import fixtures as fx import mock from oslo_utils.fixture import uuidsentinel as uuids +import six import webob from nova.api.openstack.compute import server_external_events \ @@ -22,6 +24,7 @@ from nova import exception from nova import objects from nova.objects import instance as instance_obj from nova import test +from nova.tests import fixtures from nova.tests.unit.api.openstack import fakes @@ -207,3 +210,48 @@ class ServerExternalEventsTestV251(ServerExternalEventsTestV21): self.assertEqual(400, result['events'][1]['code']) self.assertEqual('failed', result['events'][1]['status']) self.assertEqual(207, code) + + +@mock.patch('nova.objects.InstanceMappingList.get_by_instance_uuids', + fake_get_by_instance_uuids) +@mock.patch('nova.objects.InstanceList.get_by_filters', + fake_get_by_filters) +class ServerExternalEventsTestV276(ServerExternalEventsTestV21): + wsgi_api_version = '2.76' + + def setUp(self): + super(ServerExternalEventsTestV276, self).setUp() + self.useFixture(fx.EnvironmentVariable('OS_DEBUG', '1')) + + self.stdlog = self.useFixture(fixtures.StandardLogging()) + + def test_create_with_missing_tag(self): + body = self.default_body + body['events'][0]['name'] = 'power-update' + body['events'][1]['name'] = 'power-update' + result, code = self._assert_call(body, + [fake_instance_uuids[0]], + ['power-update']) + msg = "Event tag is missing for instance" + self.assertIn(msg, self.stdlog.logger.output) + self.assertEqual(200, result['events'][0]['code']) + self.assertEqual('completed', result['events'][0]['status']) + self.assertEqual(400, result['events'][1]['code']) + self.assertEqual('failed', result['events'][1]['status']) + self.assertEqual(207, code) + + def test_create_event_auth_pre_2_76_fails(self): + # Negative test to make sure you can't create 'power-update' + # before 2.76. + body = self.default_body + body['events'][0]['name'] = 'power-update' + body['events'][1]['name'] = 'power-update' + req = fakes.HTTPRequestV21.blank( + '/os-server-external-events', version='2.75') + exp = self.assertRaises( + exception.ValidationError, + self.api.create, + req, + body=body) + self.assertIn('Invalid input for field/attribute name.', + six.text_type(exp)) diff --git a/nova/tests/unit/compute/test_compute_api.py b/nova/tests/unit/compute/test_compute_api.py index bc9fa98a5b..fecb74d30e 100644 --- a/nova/tests/unit/compute/test_compute_api.py +++ b/nova/tests/unit/compute/test_compute_api.py @@ -31,6 +31,7 @@ import six from nova.compute import api as compute_api from nova.compute import flavors from nova.compute import instance_actions +from nova.compute import power_state from nova.compute import rpcapi as compute_rpcapi from nova.compute import task_states from nova.compute import utils as compute_utils @@ -3963,6 +3964,7 @@ class _ComputeAPIUnitTestMixIn(object): @mock.patch.object(objects.InstanceAction, 'action_start') def test_external_instance_event(self, mock_action_start): + instances = [ objects.Instance(uuid=uuids.instance_1, host='host1', migration_context=None), @@ -3972,6 +3974,14 @@ class _ComputeAPIUnitTestMixIn(object): migration_context=None), objects.Instance(uuid=uuids.instance_4, host='host2', migration_context=None), + objects.Instance(uuid=uuids.instance_5, host='host2', + migration_context=None, task_state=None, + vm_state=vm_states.STOPPED, + power_state=power_state.SHUTDOWN), + objects.Instance(uuid=uuids.instance_6, host='host2', + migration_context=None, task_state=None, + vm_state=vm_states.ACTIVE, + power_state=power_state.RUNNING) ] # Create a single cell context and associate it with all instances mapping = objects.InstanceMapping.get_by_instance_uuid( @@ -3996,6 +4006,14 @@ class _ComputeAPIUnitTestMixIn(object): instance_uuid=uuids.instance_4, name='volume-extended', tag=volume_id), + objects.InstanceExternalEvent( + instance_uuid=uuids.instance_5, + name='power-update', + tag="POWER_ON"), + objects.InstanceExternalEvent( + instance_uuid=uuids.instance_6, + name='power-update', + tag="POWER_OFF"), ] self.compute_api.compute_rpcapi = mock.MagicMock() self.compute_api.external_instance_event(self.context, @@ -4005,11 +4023,68 @@ class _ComputeAPIUnitTestMixIn(object): host='host1') method.assert_any_call(cell_context, instances[2:], events[2:], host='host2') - mock_action_start.assert_called_once_with( - self.context, uuids.instance_4, instance_actions.EXTEND_VOLUME, - want_result=False) + calls = [mock.call(self.context, uuids.instance_4, + instance_actions.EXTEND_VOLUME, want_result=False), + mock.call(self.context, uuids.instance_5, + instance_actions.START, want_result=False), + mock.call(self.context, uuids.instance_6, + instance_actions.STOP, want_result=False)] + mock_action_start.assert_has_calls(calls) self.assertEqual(2, method.call_count) + def test_external_instance_event_power_update_invalid_tag(self): + instance1 = objects.Instance(self.context) + instance1.uuid = uuids.instance1 + instance1.id = 1 + instance1.vm_state = vm_states.ACTIVE + instance1.task_state = None + instance1.power_state = power_state.RUNNING + instance1.host = 'host1' + instance1.migration_context = None + instance2 = objects.Instance(self.context) + instance2.uuid = uuids.instance2 + instance2.id = 2 + instance2.vm_state = vm_states.STOPPED + instance2.task_state = None + instance2.power_state = power_state.SHUTDOWN + instance2.host = 'host2' + instance2.migration_context = None + instances = [instance1, instance2] + events = [ + objects.InstanceExternalEvent( + instance_uuid=instance1.uuid, + name='power-update', + tag="VACATION"), + objects.InstanceExternalEvent( + instance_uuid=instance2.uuid, + name='power-update', + tag="POWER_ON") + ] + with test.nested( + mock.patch.object(self.compute_api.compute_rpcapi, + 'external_instance_event'), + mock.patch.object(objects.InstanceAction, 'action_start'), + mock.patch.object(compute_api, 'LOG') + ) as ( + mock_ex, mock_action_start, mock_log + ): + self.compute_api.external_instance_event(self.context, + instances, events) + self.assertEqual(2, mock_ex.call_count) + # event VACATION requested on instance1 is ignored because + # its an invalid event tag. + mock_ex.assert_has_calls( + [mock.call(self.context, [instance2], + [events[1]], host=u'host2'), + mock.call(self.context, [instance1], [], host=u'host1')], + any_order=True) + mock_action_start.assert_called_once_with( + self.context, instance2.uuid, instance_actions.START, + want_result=False) + self.assertEqual(1, mock_log.warning.call_count) + self.assertIn( + 'Invalid power state', mock_log.warning.call_args[0][0]) + def test_external_instance_event_evacuating_instance(self): # Since we're patching the db's migration_get(), use a dict here so # that we can validate the id is making its way correctly to the db api diff --git a/nova/tests/unit/compute/test_compute_mgr.py b/nova/tests/unit/compute/test_compute_mgr.py index 9dbe5a36fb..53e2715168 100644 --- a/nova/tests/unit/compute/test_compute_mgr.py +++ b/nova/tests/unit/compute/test_compute_mgr.py @@ -2980,6 +2980,135 @@ class ComputeManagerUnitTestCase(test.NoDBTestCase, do_test() + def test_power_update(self): + instance = objects.Instance(self.context) + instance.uuid = uuids.instance + instance.id = 1 + instance.vm_state = vm_states.STOPPED + instance.task_state = None + instance.power_state = power_state.SHUTDOWN + instance.host = self.compute.host + with test.nested( + mock.patch.object(nova.compute.utils, + 'notify_about_instance_action'), + mock.patch.object(self.compute, '_notify_about_instance_usage'), + mock.patch.object(self.compute.driver, 'power_update_event'), + mock.patch.object(objects.Instance, 'save'), + mock.patch.object(manager, 'LOG') + ) as ( + mock_instance_notify, mock_instance_usage, mock_event, mock_save, + mock_log + ): + self.compute.power_update(self.context, instance, "POWER_ON") + calls = [mock.call(self.context, instance, self.compute.host, + action=fields.NotificationAction.POWER_ON, + phase=fields.NotificationPhase.START), + mock.call(self.context, instance, self.compute.host, + action=fields.NotificationAction.POWER_ON, + phase=fields.NotificationPhase.END)] + mock_instance_notify.assert_has_calls(calls) + calls = [mock.call(self.context, instance, "power_on.start"), + mock.call(self.context, instance, "power_on.end")] + mock_instance_usage.assert_has_calls(calls) + mock_event.assert_called_once_with(instance, 'POWER_ON') + mock_save.assert_called_once_with( + expected_task_state=[None]) + self.assertEqual(2, mock_log.debug.call_count) + self.assertIn('Trying to', mock_log.debug.call_args[0][0]) + + def test_power_update_not_implemented(self): + instance = objects.Instance(self.context) + instance.uuid = uuids.instance + instance.id = 1 + instance.vm_state = vm_states.STOPPED + instance.task_state = None + instance.power_state = power_state.SHUTDOWN + instance.host = self.compute.host + with test.nested( + mock.patch.object(nova.compute.utils, + 'notify_about_instance_action'), + mock.patch.object(self.compute, '_notify_about_instance_usage'), + mock.patch.object(self.compute.driver, 'power_update_event', + side_effect=NotImplementedError()), + mock.patch.object(instance, 'save'), + mock.patch.object(nova.compute.utils, + 'add_instance_fault_from_exc'), + ) as ( + mock_instance_notify, mock_instance_usage, mock_event, + mock_save, mock_fault + ): + self.assertRaises(NotImplementedError, + self.compute.power_update, self.context, instance, "POWER_ON") + self.assertIsNone(instance.task_state) + self.assertEqual(2, mock_save.call_count) + # second save is done by revert_task_state + mock_save.assert_has_calls( + [mock.call(expected_task_state=[None]), mock.call()]) + mock_instance_notify.assert_called_once_with( + self.context, instance, self.compute.host, + action=fields.NotificationAction.POWER_ON, + phase=fields.NotificationPhase.START) + mock_instance_usage.assert_called_once_with( + self.context, instance, "power_on.start") + mock_fault.assert_called_once_with( + self.context, instance, mock.ANY, mock.ANY) + + def test_external_instance_event_power_update_invalid_state(self): + instance = objects.Instance(self.context) + instance.uuid = uuids.instance1 + instance.id = 1 + instance.vm_state = vm_states.ACTIVE + instance.task_state = task_states.POWERING_OFF + instance.power_state = power_state.RUNNING + instance.host = 'host1' + instance.migration_context = None + with test.nested( + mock.patch.object(nova.compute.utils, + 'notify_about_instance_action'), + mock.patch.object(self.compute, '_notify_about_instance_usage'), + mock.patch.object(self.compute.driver, 'power_update_event'), + mock.patch.object(objects.Instance, 'save'), + mock.patch.object(manager, 'LOG') + ) as ( + mock_instance_notify, mock_instance_usage, mock_event, mock_save, + mock_log + ): + self.compute.power_update(self.context, instance, "POWER_ON") + mock_instance_notify.assert_not_called() + mock_instance_usage.assert_not_called() + mock_event.assert_not_called() + mock_save.assert_not_called() + self.assertEqual(1, mock_log.info.call_count) + self.assertIn('is a no-op', mock_log.info.call_args[0][0]) + + def test_external_instance_event_power_update_unexpected_task_state(self): + instance = objects.Instance(self.context) + instance.uuid = uuids.instance1 + instance.id = 1 + instance.vm_state = vm_states.ACTIVE + instance.task_state = None + instance.power_state = power_state.RUNNING + instance.host = 'host1' + instance.migration_context = None + with test.nested( + mock.patch.object(nova.compute.utils, + 'notify_about_instance_action'), + mock.patch.object(self.compute, '_notify_about_instance_usage'), + mock.patch.object(self.compute.driver, 'power_update_event'), + mock.patch.object(objects.Instance, 'save', + side_effect=exception.UnexpectedTaskStateError("blah")), + mock.patch.object(manager, 'LOG') + ) as ( + mock_instance_notify, mock_instance_usage, mock_event, mock_save, + mock_log + ): + self.compute.power_update(self.context, instance, "POWER_OFF") + mock_instance_notify.assert_not_called() + mock_instance_usage.assert_not_called() + mock_event.assert_not_called() + self.assertEqual(1, mock_log.info.call_count) + self.assertIn('possibly preempted', mock_log.info.call_args[0][0]) + def test_extend_volume(self): inst_obj = objects.Instance(id=3, uuid=uuids.instance) connection_info = {'foo': 'bar'} @@ -3066,7 +3195,8 @@ class ComputeManagerUnitTestCase(test.NoDBTestCase, objects.Instance(id=1, uuid=uuids.instance_1), objects.Instance(id=2, uuid=uuids.instance_2), objects.Instance(id=3, uuid=uuids.instance_3), - objects.Instance(id=4, uuid=uuids.instance_4)] + objects.Instance(id=4, uuid=uuids.instance_4), + objects.Instance(id=4, uuid=uuids.instance_5)] events = [ objects.InstanceExternalEvent(name='network-changed', tag='tag1', @@ -3079,15 +3209,20 @@ class ComputeManagerUnitTestCase(test.NoDBTestCase, tag='tag3'), objects.InstanceExternalEvent(name='volume-extended', instance_uuid=uuids.instance_4, - tag='tag4')] + tag='tag4'), + objects.InstanceExternalEvent(name='power-update', + instance_uuid=uuids.instance_5, + tag='POWER_ON')] + @mock.patch.object(self.compute, 'power_update') @mock.patch.object(self.compute, 'extend_volume') @mock.patch.object(self.compute, '_process_instance_vif_deleted_event') @mock.patch.object(self.compute.network_api, 'get_instance_nw_info') @mock.patch.object(self.compute, '_process_instance_event') def do_test(_process_instance_event, get_instance_nw_info, - _process_instance_vif_deleted_event, extend_volume): + _process_instance_vif_deleted_event, extend_volume, + power_update): self.compute.external_instance_event(self.context, instances, events) get_instance_nw_info.assert_called_once_with(self.context, @@ -3099,6 +3234,9 @@ class ComputeManagerUnitTestCase(test.NoDBTestCase, self.context, instances[2], events[2].tag) extend_volume.assert_called_once_with( self.context, instances[3], events[3].tag) + power_update.assert_called_once_with( + self.context, instances[4], events[4].tag) + do_test() def test_external_instance_event_with_exception(self): diff --git a/nova/tests/unit/objects/test_objects.py b/nova/tests/unit/objects/test_objects.py index 1068032d47..b4e3c7c77f 100644 --- a/nova/tests/unit/objects/test_objects.py +++ b/nova/tests/unit/objects/test_objects.py @@ -1076,7 +1076,7 @@ object_data = { 'InstanceActionEventList': '1.1-13d92fb953030cdbfee56481756e02be', 'InstanceActionList': '1.1-a2b2fb6006b47c27076d3a1d48baa759', 'InstanceDeviceMetadata': '1.0-74d78dd36aa32d26d2769a1b57caf186', - 'InstanceExternalEvent': '1.2-23eb6ba79cde5cd06d3445f845ba4589', + 'InstanceExternalEvent': '1.3-e47782874cca95bb96e566286e9d1e23', 'InstanceFault': '1.2-7ef01f16f1084ad1304a513d6d410a38', 'InstanceFaultList': '1.2-6bb72de2872fe49ded5eb937a93f2451', 'InstanceGroup': '1.11-852ac511d30913ee88f3c3a869a8f30a', diff --git a/nova/tests/unit/virt/ironic/test_driver.py b/nova/tests/unit/virt/ironic/test_driver.py index c3f4692c72..670a2e1323 100644 --- a/nova/tests/unit/virt/ironic/test_driver.py +++ b/nova/tests/unit/virt/ironic/test_driver.py @@ -26,6 +26,7 @@ from testtools import matchers from tooz import hashring as hash_ring from nova.api.metadata import base as instance_metadata +from nova.api.openstack import common from nova import block_device from nova.compute import power_state as nova_states from nova.compute import provider_tree @@ -1864,6 +1865,20 @@ class IronicDriverTestCase(test.NoDBTestCase): mock_sp.assert_has_calls([mock.call(node.uuid, 'reboot', soft=True), mock.call(node.uuid, 'reboot')]) + @mock.patch.object(objects.Instance, 'save') + def test_power_update_event(self, mock_save): + instance = fake_instance.fake_instance_obj( + self.ctx, node=self.instance_uuid, + power_state=nova_states.RUNNING, + vm_state=vm_states.ACTIVE, + task_state=task_states.POWERING_OFF) + self.driver.power_update_event(instance, common.POWER_OFF) + self.assertEqual(nova_states.SHUTDOWN, instance.power_state) + self.assertEqual(vm_states.STOPPED, instance.vm_state) + self.assertIsNone(instance.task_state) + mock_save.assert_called_once_with( + expected_task_state=task_states.POWERING_OFF) + @mock.patch.object(loopingcall, 'FixedIntervalLoopingCall') @mock.patch.object(ironic_driver.IronicDriver, '_validate_instance_and_node') diff --git a/nova/virt/driver.py b/nova/virt/driver.py index d32ac27e63..4f05e8ffd8 100644 --- a/nova/virt/driver.py +++ b/nova/virt/driver.py @@ -865,6 +865,19 @@ class ComputeDriver(object): """ raise NotImplementedError() + def power_update_event(self, instance, target_power_state): + """Update power, vm and task states of the specified instance. + + Note that the driver is expected to set the task_state of the + instance back to None. + + :param instance: nova.objects.instance.Instance + :param target_power_state: The desired target power state for the + instance; possible values are "POWER_ON" + and "POWER_OFF". + """ + raise NotImplementedError() + def trigger_crash_dump(self, instance): """Trigger crash dump mechanism on the given instance. diff --git a/nova/virt/fake.py b/nova/virt/fake.py index 6f293de1b8..a404ab9c52 100644 --- a/nova/virt/fake.py +++ b/nova/virt/fake.py @@ -45,6 +45,7 @@ from nova.objects import fields as obj_fields from nova.objects import migrate_data from nova.virt import driver from nova.virt import hardware +from nova.virt.ironic import driver as ironic from nova.virt import virtapi CONF = nova.conf.CONF @@ -688,6 +689,18 @@ class MediumFakeDriver(FakeDriver): local_gb = 1028 +class PowerUpdateFakeDriver(SmallFakeDriver): + # A specific fake driver for the power-update external event testing. + + def __init__(self, virtapi): + super(PowerUpdateFakeDriver, self).__init__(virtapi=None) + self.driver = ironic.IronicDriver(virtapi=virtapi) + + def power_update_event(self, instance, target_power_state): + """Update power state of the specified instance in the nova DB.""" + self.driver.power_update_event(instance, target_power_state) + + class MediumFakeDriverWithNestedCustomResources(MediumFakeDriver): # A MediumFakeDriver variant that also reports CUSTOM_MAGIC resources on # a nested resource provider diff --git a/nova/virt/ironic/driver.py b/nova/virt/ironic/driver.py index b985240bc5..f624ffad36 100644 --- a/nova/virt/ironic/driver.py +++ b/nova/virt/ironic/driver.py @@ -45,6 +45,7 @@ from nova import context as nova_context from nova import exception from nova.i18n import _ from nova import objects +from nova.objects import external_event as external_event_obj from nova.objects import fields as obj_fields from nova import servicegroup from nova import utils @@ -1474,6 +1475,26 @@ class IronicDriver(virt_driver.ComputeDriver): LOG.info('Successfully powered on Ironic node %s', node.uuid, instance=instance) + def power_update_event(self, instance, target_power_state): + """Update power, vm and task states of the specified instance in + the nova DB. + """ + LOG.info('Power update called for instance with ' + 'target power state %s.', target_power_state, + instance=instance) + if target_power_state == external_event_obj.POWER_ON: + instance.power_state = power_state.RUNNING + instance.vm_state = vm_states.ACTIVE + instance.task_state = None + expected_task_state = task_states.POWERING_ON + else: + # It's POWER_OFF + instance.power_state = power_state.SHUTDOWN + instance.vm_state = vm_states.STOPPED + instance.task_state = None + expected_task_state = task_states.POWERING_OFF + instance.save(expected_task_state=expected_task_state) + def trigger_crash_dump(self, instance): """Trigger crash dump mechanism on the given instance. diff --git a/releasenotes/notes/bp-nova-support-instance-power-update-8328355a0f3fb508.yaml b/releasenotes/notes/bp-nova-support-instance-power-update-8328355a0f3fb508.yaml new file mode 100644 index 0000000000..51fc2949cf --- /dev/null +++ b/releasenotes/notes/bp-nova-support-instance-power-update-8328355a0f3fb508.yaml @@ -0,0 +1,20 @@ +--- +features: + - | + It is now possible to signal and perform an update of an instance's power + state as of the 2.76 microversion using the ``power-update`` external + event. Currently it is only supported in the ironic driver and through + this event Ironic will send all "power-on to power-off" and + "power-off to power-on" type power state changes on a physical instance + to nova which will update its database accordingly. This way nova will + not be able to enforce an incorrect power state on the physical instance + during the periodic ``_sync_power_states`` task. The changes to the power + state of an instance caused by this event can be viewed through + ``GET /servers/{server_id}/os-instance-actions`` and + ``GET /servers/{server_id}/os-instance-actions/{request_id}``. +upgrade: + - | + Until all the ``nova-compute`` services that run the ironic driver are + upgraded to the Train code that handles the ``power-update`` callbacks from + ironic, the ``[nova]/send_power_notifications`` config option can be kept + disabled in ironic. |