diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 7337a3a381e7..82f9ef8801ea 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -1799,6 +1799,15 @@ availability_zone_state: in: body required: true type: object +availability_zone_unshelve: + description: | + The availability zone name. Specifying an availability zone is only + allowed when the server status is ``SHELVED_OFFLOADED`` otherwise a + 409 HTTPConflict response is returned. + in: body + required: false + type: string + min_version: 2.77 available: description: | Returns true if the availability zone is available. diff --git a/api-ref/source/servers-action-shelve.inc b/api-ref/source/servers-action-shelve.inc index b024031cdfc5..4bb63ceb7ec4 100644 --- a/api-ref/source/servers-action-shelve.inc +++ b/api-ref/source/servers-action-shelve.inc @@ -138,7 +138,7 @@ If the server status does not change to ``ACTIVE``, the unshelve operation faile Normal response codes: 202 -Error response codes: unauthorized(401), forbidden(403), itemNotFound(404), conflict(409) +Error response codes: badRequest(400), unauthorized(401), forbidden(403), itemNotFound(404), conflict(409) Request ------- @@ -147,6 +147,7 @@ Request - server_id: server_id_path - unshelve: unshelve + - availability_zone: availability_zone_unshelve | @@ -155,6 +156,11 @@ Request .. literalinclude:: ../../doc/api_samples/os-shelve/os-unshelve.json :language: javascript +**Example Unshelve server (unshelve Action) (v2.77)** + +.. literalinclude:: ../../doc/api_samples/os-shelve/v2.77/os-unshelve.json + :language: javascript + Response -------- diff --git a/doc/api_samples/os-shelve/v2.77/os-shelve.json b/doc/api_samples/os-shelve/v2.77/os-shelve.json new file mode 100644 index 000000000000..e33b05865aca --- /dev/null +++ b/doc/api_samples/os-shelve/v2.77/os-shelve.json @@ -0,0 +1,3 @@ +{ + "shelve": null +} \ No newline at end of file diff --git a/doc/api_samples/os-shelve/v2.77/os-unshelve-null.json b/doc/api_samples/os-shelve/v2.77/os-unshelve-null.json new file mode 100644 index 000000000000..fd05c2a2fe67 --- /dev/null +++ b/doc/api_samples/os-shelve/v2.77/os-unshelve-null.json @@ -0,0 +1,3 @@ +{ + "unshelve": null +} \ No newline at end of file diff --git a/doc/api_samples/os-shelve/v2.77/os-unshelve.json b/doc/api_samples/os-shelve/v2.77/os-unshelve.json new file mode 100644 index 000000000000..8ca146b5933c --- /dev/null +++ b/doc/api_samples/os-shelve/v2.77/os-unshelve.json @@ -0,0 +1,5 @@ +{ + "unshelve": { + "availability_zone": "us-west" + } +} \ No newline at end of file diff --git a/doc/api_samples/versions/v21-version-get-resp.json b/doc/api_samples/versions/v21-version-get-resp.json index b5c1ad05e1cc..7e16157149eb 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.76", + "version": "2.77", "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 f7b96be8f219..81d4d94fe94c 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.76", + "version": "2.77", "min_version": "2.1", "updated": "2013-07-23T11:33:21Z" } diff --git a/doc/source/user/aggregates.rst b/doc/source/user/aggregates.rst index 74a1848dbc3f..6cc68fa69ca3 100644 --- a/doc/source/user/aggregates.rst +++ b/doc/source/user/aggregates.rst @@ -91,6 +91,9 @@ With respect to availability zones, a server is restricted to a zone if: parameter but the API service is configured for :oslo.config:option:`default_schedule_zone` then by default the server will be scheduled to that zone. +3. The shelved offloaded server was unshelved by specifying the + ``availability_zone`` with the ``POST /servers/{server_id}/action`` request + using microversion 2.77 or greater. If the server was not created in a specific zone then it is free to be moved to other zones, i.e. the :ref:`AvailabilityZoneFilter ` diff --git a/nova/api/openstack/api_version_request.py b/nova/api/openstack/api_version_request.py index a41c029f494e..77917a4fc2f6 100644 --- a/nova/api/openstack/api_version_request.py +++ b/nova/api/openstack/api_version_request.py @@ -201,6 +201,8 @@ REST_API_VERSION_HISTORY = """REST API Version History: can be viewed through ``GET /servers/{server_id}/os-instance-actions`` and ``GET /servers/{server_id}/os-instance-actions/{request_id}``. + * 2.77 - Add support for specifying ``availability_zone`` to unshelve of a + shelved offload server. """ # The minimum and maximum versions of the API supported @@ -209,7 +211,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.76" +_MAX_API_VERSION = "2.77" DEFAULT_API_VERSION = _MIN_API_VERSION # Almost all proxy APIs which are related to network, images and baremetal diff --git a/nova/api/openstack/compute/rest_api_version_history.rst b/nova/api/openstack/compute/rest_api_version_history.rst index 6935eef4f519..4bdc30c1f76a 100644 --- a/nova/api/openstack/compute/rest_api_version_history.rst +++ b/nova/api/openstack/compute/rest_api_version_history.rst @@ -993,3 +993,8 @@ 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}``. + +2.77 +---- +API microversion 2.77 adds support for specifying availability zone when +unshelving a shelved offloaded server. diff --git a/nova/api/openstack/compute/schemas/shelve.py b/nova/api/openstack/compute/schemas/shelve.py new file mode 100644 index 000000000000..e8d2f1c24061 --- /dev/null +++ b/nova/api/openstack/compute/schemas/shelve.py @@ -0,0 +1,37 @@ +# Copyright 2019 INSPUR Corporation. 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 nova.api.validation import parameter_types + +# NOTE(brinzhang): For older microversion there will be no change as +# schema is applied only for >2.77 with unshelve a server API. +# Anything working in old version keep working as it is. +unshelve_v277 = { + 'type': 'object', + 'properties': { + 'unshelve': { + 'type': ['object', 'null'], + 'properties': { + 'availability_zone': parameter_types.name + }, + # NOTE: The allowed request body is {'unshelve': null} or + # {'unshelve': {'availability_zone': }}, not allowed + # {'unshelve': {}} as the request body for unshelve. + 'required': ['availability_zone'], + 'additionalProperties': False, + }, + }, + 'required': ['unshelve'], + 'additionalProperties': False, +} diff --git a/nova/api/openstack/compute/shelve.py b/nova/api/openstack/compute/shelve.py index 9bc172da8cce..3293e07fa0a9 100644 --- a/nova/api/openstack/compute/shelve.py +++ b/nova/api/openstack/compute/shelve.py @@ -16,8 +16,11 @@ from webob import exc +from nova.api.openstack import api_version_request from nova.api.openstack import common +from nova.api.openstack.compute.schemas import shelve as shelve_schemas from nova.api.openstack import wsgi +from nova.api import validation from nova.compute import api as compute from nova.compute import vm_states from nova import exception @@ -72,12 +75,23 @@ class ShelveController(wsgi.Controller): @wsgi.response(202) @wsgi.expected_errors((400, 404, 409)) @wsgi.action('unshelve') + # In microversion 2.77 we support specifying 'availability_zone' to + # unshelve a server. But before 2.77 there is no request body + # schema validation (because of body=null). + @validation.schema(shelve_schemas.unshelve_v277, min_version='2.77') def _unshelve(self, req, id, body): """Restore an instance from shelved mode.""" context = req.environ["nova.context"] context.can(shelve_policies.POLICY_ROOT % 'unshelve') instance = common.get_instance(self.compute_api, context, id) + new_az = None + unshelve_dict = body['unshelve'] + if unshelve_dict and 'availability_zone' in unshelve_dict: + support_az = api_version_request.is_supported(req, '2.77') + if support_az: + new_az = unshelve_dict['availability_zone'] + # We could potentially move this check to conductor and avoid the # extra API call to neutron when we support move operations with ports # having resource requests. @@ -93,10 +107,14 @@ class ShelveController(wsgi.Controller): raise exc.HTTPBadRequest(explanation=msg) try: - self.compute_api.unshelve(context, instance) - except exception.InstanceIsLocked as e: + self.compute_api.unshelve(context, instance, new_az=new_az) + except (exception.InstanceIsLocked, + exception.UnshelveInstanceInvalidState, + exception.MismatchVolumeAZException) as e: raise exc.HTTPConflict(explanation=e.format_message()) except exception.InstanceInvalidState as state_error: common.raise_http_conflict_for_instance_invalid_state(state_error, 'unshelve', id) + except exception.InvalidRequest as e: + raise exc.HTTPBadRequest(explanation=e.format_message()) diff --git a/nova/compute/api.py b/nova/compute/api.py index af97fb7298a0..7bffd9878e44 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -3658,14 +3658,83 @@ class API(base.Base): self.compute_rpcapi.shelve_offload_instance(context, instance=instance, clean_shutdown=clean_shutdown) + def _validate_unshelve_az(self, context, instance, availability_zone): + """Verify the specified availability_zone during unshelve. + + Verifies that the server is shelved offloaded, the AZ exists and + if [cinder]/cross_az_attach=False, that any attached volumes are in + the same AZ. + + :param context: nova auth RequestContext for the unshelve action + :param instance: Instance object for the server being unshelved + :param availability_zone: The user-requested availability zone in + which to unshelve the server. + :raises: UnshelveInstanceInvalidState if the server is not shelved + offloaded + :raises: InvalidRequest if the requested AZ does not exist + :raises: MismatchVolumeAZException if [cinder]/cross_az_attach=False + and any attached volumes are not in the requested AZ + """ + if instance.vm_state != vm_states.SHELVED_OFFLOADED: + # NOTE(brinzhang): If the server status is 'SHELVED', it still + # belongs to a host, the availability_zone has not changed. + # Unshelving a shelved offloaded server will go through the + # scheduler to find a new host. + raise exception.UnshelveInstanceInvalidState( + state=instance.vm_state, instance_uuid=instance.uuid) + + available_zones = availability_zones.get_availability_zones( + context, self.host_api, get_only_available=True) + if availability_zone not in available_zones: + msg = _('The requested availability zone is not available') + raise exception.InvalidRequest(msg) + + # NOTE(brinzhang): When specifying a availability zone to unshelve + # a shelved offloaded server, and conf cross_az_attach=False, need + # to determine if attached volume AZ matches the user-specified AZ. + if not CONF.cinder.cross_az_attach: + bdms = objects.BlockDeviceMappingList.get_by_instance_uuid( + context, instance.uuid) + for bdm in bdms: + if bdm.is_volume and bdm.volume_id: + volume = self.volume_api.get(context, bdm.volume_id) + if availability_zone != volume['availability_zone']: + msg = _("The specified availability zone does not " + "match the volume %(vol_id)s attached to the " + "server. Specified availability zone is " + "%(az)s. Volume is in %(vol_zone)s.") % { + "vol_id": volume['id'], + "az": availability_zone, + "vol_zone": volume['availability_zone']} + raise exception.MismatchVolumeAZException(reason=msg) + @check_instance_lock @check_instance_state(vm_state=[vm_states.SHELVED, vm_states.SHELVED_OFFLOADED]) - def unshelve(self, context, instance): + def unshelve(self, context, instance, new_az=None): """Restore a shelved instance.""" request_spec = objects.RequestSpec.get_by_instance_uuid( context, instance.uuid) + if new_az: + self._validate_unshelve_az(context, instance, new_az) + LOG.debug("Replace the old AZ %(old_az)s in RequestSpec " + "with a new AZ %(new_az)s of the instance.", + {"old_az": request_spec.availability_zone, + "new_az": new_az}, instance=instance) + # Unshelving a shelved offloaded server will go through the + # scheduler to pick a new host, so we update the + # RequestSpec.availability_zone here. Note that if scheduling + # fails the RequestSpec will remain updated, which is not great, + # but if we want to change that we need to defer updating the + # RequestSpec until conductor which probably means RPC changes to + # pass the new_az variable to conductor. This is likely low + # priority since the RequestSpec.availability_zone on a shelved + # offloaded server does not mean much anyway and clearly the user + # is trying to put the server in the target AZ. + request_spec.availability_zone = new_az + request_spec.save() + instance.task_state = task_states.UNSHELVING instance.save(expected_task_state=[None]) diff --git a/nova/exception.py b/nova/exception.py index cd2079440c3d..4d0db83f6b1c 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -1863,6 +1863,19 @@ class UnshelveException(NovaException): msg_fmt = _("Error during unshelve instance %(instance_id)s: %(reason)s") +class MismatchVolumeAZException(Invalid): + msg_fmt = _("The availability zone between the server and its attached " + "volumes do not match: %(reason)s.") + code = 409 + + +class UnshelveInstanceInvalidState(InstanceInvalidState): + msg_fmt = _('Specifying an availability zone when unshelving server ' + '%(instance_uuid)s with status "%(state)s" is not supported. ' + 'The server status must be SHELVED_OFFLOADED.') + code = 409 + + class ImageVCPULimitsRangeExceeded(Invalid): msg_fmt = _('Image vCPU topology limits (sockets=%(image_sockets)d, ' 'cores=%(image_cores)d, threads=%(image_threads)d) exceeds ' diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-shelve.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-shelve.json.tpl new file mode 100644 index 000000000000..5a19f85cffa9 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-shelve.json.tpl @@ -0,0 +1,3 @@ +{ + "%(action)s": null +} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-unshelve-null.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-unshelve-null.json.tpl new file mode 100644 index 000000000000..5a19f85cffa9 --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-unshelve-null.json.tpl @@ -0,0 +1,3 @@ +{ + "%(action)s": null +} diff --git a/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-unshelve.json.tpl b/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-unshelve.json.tpl new file mode 100644 index 000000000000..9bcd25139a2c --- /dev/null +++ b/nova/tests/functional/api_sample_tests/api_samples/os-shelve/v2.77/os-unshelve.json.tpl @@ -0,0 +1,5 @@ +{ + "%(action)s": { + "availability_zone": "%(availability_zone)s" + } +} diff --git a/nova/tests/functional/api_sample_tests/test_shelve.py b/nova/tests/functional/api_sample_tests/test_shelve.py index ab6b75aa7280..9c55817608af 100644 --- a/nova/tests/functional/api_sample_tests/test_shelve.py +++ b/nova/tests/functional/api_sample_tests/test_shelve.py @@ -47,3 +47,29 @@ class ShelveJsonTest(test_servers.ServersSampleBase): uuid = self._post_server() self._test_server_action(uuid, 'os-shelve', 'shelve') self._test_server_action(uuid, 'os-unshelve', 'unshelve') + + +class UnshelveJson277Test(test_servers.ServersSampleBase): + sample_dir = "os-shelve" + microversion = '2.77' + scenarios = [('v2_77', {'api_major_version': 'v2.1'})] + USE_NEUTRON = True + + def _test_server_action(self, uuid, template, action, subs=None): + subs = subs or {} + subs.update({'action': action}) + response = self._do_post('servers/%s/action' % uuid, + template, subs) + self.assertEqual(202, response.status_code) + self.assertEqual("", response.text) + + def test_unshelve_with_az(self): + uuid = self._post_server() + self._test_server_action(uuid, 'os-shelve', 'shelve') + self._test_server_action(uuid, 'os-unshelve', 'unshelve', + subs={"availability_zone": "us-west"}) + + def test_unshelve_no_az(self): + uuid = self._post_server() + self._test_server_action(uuid, 'os-shelve', 'shelve') + self._test_server_action(uuid, 'os-unshelve-null', 'unshelve') diff --git a/nova/tests/functional/test_servers.py b/nova/tests/functional/test_servers.py index 925eaf9f5c6d..fbb19c855e77 100644 --- a/nova/tests/functional/test_servers.py +++ b/nova/tests/functional/test_servers.py @@ -2577,7 +2577,7 @@ class ServerMovingTests(integrated_helpers.ProviderUsageBaseTestCase): source_hostname, source_rp_uuid) req = { - 'unshelve': {} + 'unshelve': None } self.api.post_server_action(server['id'], req) self._wait_for_state_change(self.api, server, 'ACTIVE') @@ -2626,7 +2626,7 @@ class ServerMovingTests(integrated_helpers.ProviderUsageBaseTestCase): self.admin_api.put_service(source_service_id, {'status': 'disabled'}) req = { - 'unshelve': {} + 'unshelve': None } self.api.post_server_action(server['id'], req) server = self._wait_for_state_change(self.api, server, 'ACTIVE') @@ -2662,7 +2662,7 @@ class ServerMovingTests(integrated_helpers.ProviderUsageBaseTestCase): self.admin_api.put_service(source_service_id, {'status': 'disabled'}) req = { - 'unshelve': {} + 'unshelve': None } self.api.post_server_action(server['id'], req) server = self._wait_for_state_change(self.api, server, 'ACTIVE') @@ -3162,7 +3162,7 @@ class ServerMovingTests(integrated_helpers.ProviderUsageBaseTestCase): binary='nova-compute')[0]['id'] self.admin_api.put_service(source_service_id, {'status': 'disabled'}) - req = {'unshelve': {}} + req = {'unshelve': None} self.api.post_server_action(created_server['id'], req) new_server = self._wait_for_state_change( self.api, created_server, 'ACTIVE') @@ -5906,7 +5906,7 @@ class UnsupportedPortResourceRequestBasedSchedulingTest( ex = self.assertRaises( client.OpenStackApiException, - self.api.post_server_action, server['id'], {'unshelve': {}}) + self.api.post_server_action, server['id'], {'unshelve': None}) self.assertEqual(400, ex.response.status_code) self.assertIn( @@ -5939,7 +5939,7 @@ class UnsupportedPortResourceRequestBasedSchedulingTest( # can exist with such a port. self._add_resource_request_to_a_bound_port(self.neutron.port_1['id']) - self.api.post_server_action(server['id'], {'unshelve': {}}) + self.api.post_server_action(server['id'], {'unshelve': None}) self._wait_for_state_change(self.admin_api, server, 'ACTIVE') diff --git a/nova/tests/unit/api/openstack/compute/test_shelve.py b/nova/tests/unit/api/openstack/compute/test_shelve.py index 257bc5366daa..88488b9b33d4 100644 --- a/nova/tests/unit/api/openstack/compute/test_shelve.py +++ b/nova/tests/unit/api/openstack/compute/test_shelve.py @@ -14,10 +14,14 @@ import mock from oslo_policy import policy as oslo_policy +from oslo_serialization import jsonutils from oslo_utils.fixture import uuidsentinel +import six import webob +from nova.api.openstack import api_version_request from nova.api.openstack.compute import shelve as shelve_v21 +from nova.compute import vm_states from nova import exception from nova import policy from nova import test @@ -49,7 +53,7 @@ class ShelvePolicyTestV21(test.NoDBTestCase): self.stub_out('nova.compute.api.API.unshelve', fakes.fake_actions_to_locked_server) self.assertRaises(webob.exc.HTTPConflict, self.controller._unshelve, - self.req, uuidsentinel.fake, {}) + self.req, uuidsentinel.fake, body={'unshelve': {}}) @mock.patch('nova.api.openstack.common.get_instance') def test_shelve_offload_locked_server(self, get_instance_mock): @@ -165,7 +169,7 @@ class ShelvePolicyEnforcementV21(test.NoDBTestCase): policy.set_rules(oslo_policy.Rules.from_dict(rules)) self.assertRaises(exception.Forbidden, self.controller._unshelve, - self.req, uuidsentinel.fake, {}) + self.req, uuidsentinel.fake, body={'unshelve': {}}) def test_unshelve_policy_failed(self): rule_name = "os_compute_api:os-shelve:unshelve" @@ -177,3 +181,118 @@ class ShelvePolicyEnforcementV21(test.NoDBTestCase): self.assertEqual( "Policy doesn't allow %s to be performed." % rule_name, exc.format_message()) + + +class UnshelveServerControllerTestV277(test.NoDBTestCase): + """Server controller test for microversion 2.77 + + Add availability_zone parameter to unshelve a shelved-offloaded server of + 2.77 microversion. + """ + wsgi_api_version = '2.77' + + def setUp(self): + super(UnshelveServerControllerTestV277, self).setUp() + self.controller = shelve_v21.ShelveController() + self.req = fakes.HTTPRequest.blank('/fake/servers/a/action', + use_admin_context=True, + version=self.wsgi_api_version) + # These tests don't care about ports with QoS bandwidth resources. + self.stub_out('nova.api.openstack.common.' + 'instance_has_port_with_resource_request', + lambda *a, **kw: False) + + def fake_get_instance(self): + ctxt = self.req.environ['nova.context'] + return fake_instance.fake_instance_obj( + ctxt, uuid=fakes.FAKE_UUID, vm_state=vm_states.SHELVED_OFFLOADED) + + @mock.patch('nova.api.openstack.common.get_instance') + def test_unshelve_with_az_pre_2_77_failed(self, mock_get_instance): + """Make sure specifying an AZ before microversion 2.77 is ignored.""" + instance = self.fake_get_instance() + mock_get_instance.return_value = instance + + body = { + 'unshelve': { + 'availability_zone': 'us-east' + }} + self.req.body = jsonutils.dump_as_bytes(body) + self.req.api_version_request = (api_version_request. + APIVersionRequest('2.76')) + with mock.patch.object(self.controller.compute_api, + 'unshelve') as mock_unshelve: + self.controller._unshelve(self.req, fakes.FAKE_UUID, body=body) + mock_unshelve.assert_called_once_with( + self.req.environ['nova.context'], instance, new_az=None) + + @mock.patch('nova.compute.api.API.unshelve') + @mock.patch('nova.api.openstack.common.get_instance') + def test_unshelve_with_none_pre_2_77_success( + self, mock_get_instance, mock_unshelve): + """Make sure we can unshelve server with None + before microversion 2.77. + """ + instance = self.fake_get_instance() + mock_get_instance.return_value = instance + + body = {'unshelve': None} + self.req.body = jsonutils.dump_as_bytes(body) + self.req.api_version_request = (api_version_request. + APIVersionRequest('2.76')) + self.controller._unshelve(self.req, fakes.FAKE_UUID, body=body) + mock_unshelve.assert_called_once_with( + self.req.environ['nova.context'], instance, new_az=None) + + @mock.patch('nova.compute.api.API.unshelve') + @mock.patch('nova.api.openstack.common.get_instance') + def test_unshelve_with_empty_dict_with_v2_77_failed( + self, mock_get_instance, mock_unshelve): + """Make sure we cannot unshelve server with empty dict.""" + instance = self.fake_get_instance() + mock_get_instance.return_value = instance + + body = {'unshelve': {}} + self.req.body = jsonutils.dump_as_bytes(body) + exc = self.assertRaises(exception.ValidationError, + self.controller._unshelve, + self.req, fakes.FAKE_UUID, + body=body) + self.assertIn("\'availability_zone\' is a required property", + six.text_type(exc)) + + def test_invalid_az_name_with_int(self): + body = { + 'unshelve': { + 'availability_zone': 1234 + }} + self.req.body = jsonutils.dump_as_bytes(body) + self.assertRaises(exception.ValidationError, + self.controller._unshelve, + self.req, fakes.FAKE_UUID, + body=body) + + def test_no_az_value(self): + body = { + 'unshelve': { + 'availability_zone': None + }} + self.req.body = jsonutils.dump_as_bytes(body) + self.assertRaises(exception.ValidationError, + self.controller._unshelve, + self.req, fakes.FAKE_UUID, + body=body) + + def test_unshelve_with_additional_param(self): + body = { + 'unshelve': { + 'availability_zone': 'us-east', + 'additional_param': 1 + }} + self.req.body = jsonutils.dump_as_bytes(body) + exc = self.assertRaises( + exception.ValidationError, + self.controller._unshelve, self.req, + fakes.FAKE_UUID, body=body) + self.assertIn("Additional properties are not allowed", + six.text_type(exc)) diff --git a/nova/tests/unit/compute/test_shelve.py b/nova/tests/unit/compute/test_shelve.py index e53edc32cbff..240d651ec0d6 100644 --- a/nova/tests/unit/compute/test_shelve.py +++ b/nova/tests/unit/compute/test_shelve.py @@ -15,6 +15,7 @@ from oslo_utils import fixture as utils_fixture from oslo_utils.fixture import uuidsentinel as uuids from oslo_utils import timeutils +from nova.compute import api as compute_api from nova.compute import claims from nova.compute import instance_actions from nova.compute import power_state @@ -809,8 +810,7 @@ class ShelveComputeAPITestCase(test_compute.BaseTestCase): for state in invalid_vm_states: self._test_shelve_offload_invalid_state(state) - @mock.patch.object(objects.RequestSpec, 'get_by_instance_uuid') - def test_unshelve(self, get_by_instance_uuid): + def _get_specify_state_instance(self, vm_state): # Ensure instance can be unshelved. instance = self._create_fake_instance_obj() @@ -819,9 +819,16 @@ class ShelveComputeAPITestCase(test_compute.BaseTestCase): self.compute_api.shelve(self.context, instance) instance.task_state = None - instance.vm_state = vm_states.SHELVED + instance.vm_state = vm_state instance.save() + return instance + + @mock.patch.object(objects.RequestSpec, 'get_by_instance_uuid') + def test_unshelve(self, get_by_instance_uuid): + # Ensure instance can be unshelved. + instance = self._get_specify_state_instance(vm_states.SHELVED) + fake_spec = objects.RequestSpec() get_by_instance_uuid.return_value = fake_spec with mock.patch.object(self.compute_api.compute_task_api, @@ -834,3 +841,116 @@ class ShelveComputeAPITestCase(test_compute.BaseTestCase): self.assertEqual(instance.task_state, task_states.UNSHELVING) db.instance_destroy(self.context, instance['uuid']) + + @mock.patch('nova.availability_zones.get_availability_zones', + return_value=['az1', 'az2']) + @mock.patch.object(objects.RequestSpec, 'save') + @mock.patch.object(objects.RequestSpec, 'get_by_instance_uuid') + def test_specified_az_ushelve_invalid_request(self, + get_by_instance_uuid, + mock_save, + mock_availability_zones): + # Ensure instance can be unshelved. + instance = self._get_specify_state_instance( + vm_states.SHELVED_OFFLOADED) + + new_az = "fake-new-az" + fake_spec = objects.RequestSpec() + fake_spec.availability_zone = "fake-old-az" + get_by_instance_uuid.return_value = fake_spec + + exc = self.assertRaises(exception.InvalidRequest, + self.compute_api.unshelve, + self.context, instance, new_az=new_az) + self.assertEqual("The requested availability zone is not available", + exc.format_message()) + + @mock.patch('nova.availability_zones.get_availability_zones', + return_value=['az1', 'az2']) + @mock.patch.object(objects.RequestSpec, 'save') + @mock.patch.object(objects.RequestSpec, 'get_by_instance_uuid') + def test_specified_az_unshelve_invalid_state(self, get_by_instance_uuid, + mock_save, + mock_availability_zones): + # Ensure instance can be unshelved. + instance = self._get_specify_state_instance(vm_states.SHELVED) + + new_az = "az1" + fake_spec = objects.RequestSpec() + fake_spec.availability_zone = "fake-old-az" + get_by_instance_uuid.return_value = fake_spec + + self.assertRaises(exception.UnshelveInstanceInvalidState, + self.compute_api.unshelve, + self.context, instance, new_az=new_az) + + @mock.patch('nova.objects.BlockDeviceMappingList.get_by_instance_uuid', + new_callable=mock.NonCallableMock) + @mock.patch('nova.availability_zones.get_availability_zones') + def test_validate_unshelve_az_cross_az_attach_true( + self, mock_get_azs, mock_get_bdms): + """Tests a case where the new AZ to unshelve does not match the volume + attached to the server but cross_az_attach=True so it's not an error. + """ + # Ensure instance can be unshelved. + instance = self._create_fake_instance_obj( + params=dict(vm_state=vm_states.SHELVED_OFFLOADED)) + + new_az = "west_az" + mock_get_azs.return_value = ["west_az", "east_az"] + self.flags(cross_az_attach=True, group='cinder') + self.compute_api._validate_unshelve_az(self.context, instance, new_az) + mock_get_azs.assert_called_once_with( + self.context, self.compute_api.host_api, get_only_available=True) + + @mock.patch('nova.volume.cinder.API.get') + @mock.patch('nova.objects.BlockDeviceMappingList.get_by_instance_uuid') + @mock.patch('nova.availability_zones.get_availability_zones') + def test_validate_unshelve_az_cross_az_attach_false( + self, mock_get_azs, mock_get_bdms, mock_get): + """Tests a case where the new AZ to unshelve does not match the volume + attached to the server and cross_az_attach=False so it's an error. + """ + # Ensure instance can be unshelved. + instance = self._create_fake_instance_obj( + params=dict(vm_state=vm_states.SHELVED_OFFLOADED)) + + new_az = "west_az" + mock_get_azs.return_value = ["west_az", "east_az"] + + bdms = [objects.BlockDeviceMapping(destination_type='volume', + volume_id=uuids.volume_id)] + mock_get_bdms.return_value = bdms + volume = {'id': uuids.volume_id, 'availability_zone': 'east_az'} + mock_get.return_value = volume + + self.flags(cross_az_attach=False, group='cinder') + self.assertRaises(exception.MismatchVolumeAZException, + self.compute_api._validate_unshelve_az, + self.context, instance, new_az) + mock_get_azs.assert_called_once_with( + self.context, self.compute_api.host_api, get_only_available=True) + mock_get_bdms.assert_called_once_with(self.context, instance.uuid) + mock_get.assert_called_once_with(self.context, uuids.volume_id) + + @mock.patch.object(compute_api.API, '_validate_unshelve_az') + @mock.patch.object(objects.RequestSpec, 'save') + @mock.patch.object(objects.RequestSpec, 'get_by_instance_uuid') + def test_specified_az_unshelve(self, get_by_instance_uuid, + mock_save, mock_validate_unshelve_az): + # Ensure instance can be unshelved. + instance = self._get_specify_state_instance( + vm_states.SHELVED_OFFLOADED) + + new_az = "west_az" + fake_spec = objects.RequestSpec() + fake_spec.availability_zone = "fake-old-az" + get_by_instance_uuid.return_value = fake_spec + + self.compute_api.unshelve(self.context, instance, new_az=new_az) + + mock_save.assert_called_once_with() + self.assertEqual(new_az, fake_spec.availability_zone) + + mock_validate_unshelve_az.assert_called_once_with( + self.context, instance, new_az) diff --git a/releasenotes/notes/bp-specifying-az-to-unshelve-server-aa355fef1eab2c02.yaml b/releasenotes/notes/bp-specifying-az-to-unshelve-server-aa355fef1eab2c02.yaml new file mode 100644 index 000000000000..41a792ad8dc2 --- /dev/null +++ b/releasenotes/notes/bp-specifying-az-to-unshelve-server-aa355fef1eab2c02.yaml @@ -0,0 +1,14 @@ +--- +features: + - | + Microversion 2.77 adds the optional parameter ``availability_zone`` to + the ``unshelve`` server action API. + + * Specifying an availability zone is only allowed when the server status + is ``SHELVED_OFFLOADED`` otherwise a 409 HTTPConflict response is + returned. + + * If the ``[cinder]/cross_az_attach`` configuration option is False then + the specified availability zone has to be the same as the availability + zone of any volumes attached to the shelved offloaded server, otherwise + a 409 HTTPConflict error response is returned.