diff --git a/nova/api/openstack/compute/migrate_server.py b/nova/api/openstack/compute/migrate_server.py index 8ab2c6c5dd..cd177ba2a6 100644 --- a/nova/api/openstack/compute/migrate_server.py +++ b/nova/api/openstack/compute/migrate_server.py @@ -163,6 +163,7 @@ class MigrateServerController(wsgi.Controller): except ( exception.ComputeHostNotFound, exception.ExtendedResourceRequestOldCompute, + exception.VTPMOldCompute, )as e: raise exc.HTTPBadRequest(explanation=e.format_message()) except exception.InstanceInvalidState as state_error: diff --git a/nova/compute/api.py b/nova/compute/api.py index 2cdaab6e96..b846ef7abc 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -129,6 +129,8 @@ SUPPORT_SHARES = 67 MIN_COMPUTE_SOUND_MODEL_TRAITS = 69 MIN_COMPUTE_USB_MODEL_TRAITS = 70 +MIN_COMPUTE_VTPM_LIVE_MIGRATION = None + # FIXME(danms): Keep a global cache of the cells we find the # first time we look. This needs to be refreshed on a timer or # trigger. @@ -268,6 +270,33 @@ def reject_sev_instances(operation): return outer +def reject_legacy_vtpm_live_migration(function): + + @functools.wraps(function) + def inner(self, context, instance, *args, **kwargs): + if hardware.get_vtpm_constraint( + instance.flavor, instance.image_meta): + # Only certain TPM secret security modes support live migration. + security = hardware.get_tpm_secret_security_constraint( + instance.flavor) or 'user' + if security != 'host': + raise exception.OperationNotSupportedForVTPM( + instance_uuid=instance.uuid, + operation=instance_actions.LIVE_MIGRATION) + # We need not check all cells because live migration only works + # within a single cell. + im = objects.InstanceMapping.get_by_instance_uuid(context, + instance.uuid) + with nova_context.target_cell(context, im.cell_mapping) as cctxt: + min_ver = objects.service.Service.get_minimum_version( + cctxt, 'nova-compute') + if (MIN_COMPUTE_VTPM_LIVE_MIGRATION is None or + min_ver < MIN_COMPUTE_VTPM_LIVE_MIGRATION): + raise exception.VTPMOldCompute() + return function(self, context, instance, *args, **kwargs) + return inner + + def reject_vtpm_instances(operation): """Reject requests to decorated function if instance has vTPM enabled. @@ -5612,7 +5641,7 @@ class API: until=MIN_COMPUTE_VDPA_HOTPLUG_LIVE_MIGRATION ) @block_accelerators() - @reject_vtpm_instances(instance_actions.LIVE_MIGRATION) + @reject_legacy_vtpm_live_migration @reject_sev_instances(instance_actions.LIVE_MIGRATION) @check_instance_lock @check_instance_state(vm_state=[vm_states.ACTIVE, vm_states.PAUSED]) diff --git a/nova/exception.py b/nova/exception.py index 22cd274c3b..2083758744 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -2670,3 +2670,9 @@ class InstanceEventTimeout(Exception): class VTPMSecretForbidden(Forbidden): pass + + +class VTPMOldCompute(Invalid): + msg_fmt = _('vTPM live migration is not supported by old nova-compute ' + 'services. Upgrade your nova-compute services to ' + 'Gazpacho (33.0.0) or later.') diff --git a/nova/tests/functional/integrated_helpers.py b/nova/tests/functional/integrated_helpers.py index 3898ea0afa..c7ec524fcc 100644 --- a/nova/tests/functional/integrated_helpers.py +++ b/nova/tests/functional/integrated_helpers.py @@ -642,9 +642,10 @@ class InstanceHelperMixin: def _live_migrate( self, server, migration_expected_state='completed', - server_expected_state='ACTIVE', + server_expected_state='ACTIVE', api=None, ): - self.api.post_server_action( + api = api or self.api + api.post_server_action( server['id'], {'os-migrateLive': {'host': None, 'block_migration': 'auto'}}) self._wait_for_migration_status(server, [migration_expected_state]) diff --git a/nova/tests/functional/libvirt/test_vtpm.py b/nova/tests/functional/libvirt/test_vtpm.py index c4b90b4939..9d8f7d5f48 100644 --- a/nova/tests/functional/libvirt/test_vtpm.py +++ b/nova/tests/functional/libvirt/test_vtpm.py @@ -26,6 +26,7 @@ from oslo_utils import uuidutils import nova.conf from nova import context as nova_context from nova import crypto +from nova.db.main import api as db_api from nova import exception from nova import objects from nova.tests.functional.api import client @@ -149,6 +150,8 @@ class VTPMServersTest(base.ServersTestBase): # Reflect reality more for async API requests like migration CAST_AS_CALL = False + # Enables block_migration='auto' required by the _live_migrate() helper. + microversion = '2.25' def setUp(self): # enable vTPM and use our own fake key service @@ -342,6 +345,146 @@ class VTPMServersTest(base.ServersTestBase): self.assertNotIn(instance.system_metadata['vtpm_secret_uuid'], conn._secrets) + def test_live_migrate_server_secret_security_user_too_old(self): + """Test behavior when a new server tries to migrate to an old compute + + We will simulate a migration attempt to an old host by setting the + service version of the destination to an old version and starting it + without any supported_tpm_secret_security. Then we will try to live + migrate to it. + + This should fail with BadRequest because the TPM secret security + policy 'user' is not allowed to live migrate. + """ + self.flags( + supported_tpm_secret_security=['user', 'host'], group='libvirt') + self.start_compute(hostname='src') + + server = self._create_server_with_vtpm(secret_security='user') + + # Set the destination compute to fake the old version. We need to use + # the DB API directly to get around the minimum service version check + # in the Service object save() method. + self.start_compute(hostname='dest') + ctx = nova_context.get_admin_context() + db_api.service_update( + ctx, self.computes['dest'].service_ref.id, {'version': 70}) + + ex = self.assertRaises( + client.OpenStackApiException, self._live_migrate, server, + api=self.admin_api) + self.assertEqual(400, ex.response.status_code) + msg = "'live-migration' not supported for vTPM-enabled instance" + self.assertIn(msg, str(ex)) + + def test_live_migrate_server_secret_security_host_too_old(self): + """Test behavior when a new server tries to migrate to an old compute + + We will simulate a migration attempt to an old host by setting the + service version of the destination to an old version and starting it + without any supported_tpm_secret_security. Then we will try to live + migrate to it. + + This should fail with BadRequest because of the service version check. + """ + self.flags(supported_tpm_secret_security=['host'], group='libvirt') + self.start_compute(hostname='src') + + server = self._create_server_with_vtpm(secret_security='host') + + # Set the destination compute to fake the old version. We need to use + # the DB API directly to get around the minimum service version check + # in the Service object save() method. + self.start_compute(hostname='dest') + ctx = nova_context.get_admin_context() + db_api.service_update( + ctx, self.computes['dest'].service_ref.id, {'version': 70}) + + ex = self.assertRaises( + client.OpenStackApiException, self._live_migrate, server, + api=self.admin_api) + self.assertEqual(400, ex.response.status_code) + self.assertIn( + 'vTPM live migration is not supported by old nova-compute ' + 'services. Upgrade your nova-compute services to ' + 'Gazpacho (33.0.0) or later.', str(ex)) + + def test_live_migrate_host_server_secret_security_host_too_old(self): + """Test behavior when a new server tries to migrate to an old compute + + This will request a destination host for live migration. + + We will simulate a migration attempt to an old host by setting the + service version of the destination to an old version and starting it + without any supported_tpm_secret_security. Then we will try to live + migrate to it. + + This should fail with BadRequest because of the service version check. + """ + self.flags(supported_tpm_secret_security=['host'], group='libvirt') + self.start_compute(hostname='src') + + server = self._create_server_with_vtpm(secret_security='host') + + # Set the destination compute to fake the old version. We need to use + # the DB API directly to get around the minimum service version check + # in the Service object save() method. + self.start_compute(hostname='dest') + ctx = nova_context.get_admin_context() + db_api.service_update( + ctx, self.computes['dest'].service_ref.id, {'version': 70}) + + ex = self.assertRaises( + client.OpenStackApiException, self._live_migrate, server, + api=self.admin_api) + self.assertEqual(400, ex.response.status_code) + self.assertIn( + 'vTPM live migration is not supported by old nova-compute ' + 'services. Upgrade your nova-compute services to ' + 'Gazpacho (33.0.0) or later.', str(ex)) + + def test_live_migrate_host_force_server_secret_security_host_too_old(self): + """Test behavior when a new server tries to migrate to an old compute + + This will request a destination host for live migration and force=True + by using an older microversion 2.30. + + We will simulate a migration attempt to an old host by setting the + service version of the destination to an old version and starting it + without any supported_tpm_secret_security. Then we will try to live + migrate to it. + + This should fail with BadRequest because of the service version check. + """ + self.flags(supported_tpm_secret_security=['host'], group='libvirt') + self.start_compute(hostname='src') + self.src = self.computes['src'] + + self.server = self._create_server_with_vtpm(secret_security='host') + + # Set the destination compute to fake the old version. We need to use + # the DB API directly to get around the minimum service version check + # in the Service object save() method. + self.start_compute(hostname='dest') + self.dest = self.computes['dest'] + ctx = nova_context.get_admin_context() + db_api.service_update(ctx, self.dest.service_ref.id, {'version': 70}) + + # The request should be rejected by the API with a 400 Bad Request due + # to the destination host service version being too old. + with utils.temporary_mutation(self.admin_api, microversion='2.30'): + ex = self.assertRaises( + client.OpenStackApiException, + self.admin_api.post_server_action, self.server['id'], + {'os-migrateLive': {'host': 'dest', + 'block_migration': 'auto', + 'force': 'True'}}) + self.assertEqual(400, ex.response.status_code) + self.assertIn( + 'vTPM live migration is not supported by old nova-compute ' + 'services. Upgrade your nova-compute services to ' + 'Gazpacho (33.0.0) or later.', str(ex)) + def test_suspend_resume_server(self): self.start_compute() @@ -787,9 +930,12 @@ class VTPMServersTest(base.ServersTestBase): self.assertInstanceHasSecret(server) # live migrate the server - self.assertRaises( + ex = self.assertRaises( client.OpenStackApiException, - self._live_migrate_server, server) + self._live_migrate_server, server, api=self.admin_api) + self.assertEqual(400, ex.response.status_code) + msg = "'live-migration' not supported for vTPM-enabled instance" + self.assertIn(msg, str(ex)) def test_shelve_server(self): for host in ('test_compute0', 'test_compute1'): diff --git a/nova/tests/unit/compute/test_api.py b/nova/tests/unit/compute/test_api.py index ce32cee1df..f2fed17b24 100644 --- a/nova/tests/unit/compute/test_api.py +++ b/nova/tests/unit/compute/test_api.py @@ -7504,6 +7504,7 @@ class _ComputeAPIUnitTestMixIn(object): # TODO(stephenfin): The separation of the mixin is a hangover from cells v1 # days and should be removed +@ddt.ddt class ComputeAPIUnitTestCase(_ComputeAPIUnitTestMixIn, test.NoDBTestCase): def setUp(self): super(ComputeAPIUnitTestCase, self).setUp() @@ -8749,3 +8750,57 @@ class ComputeAPIUnitTestCase(_ComputeAPIUnitTestMixIn, test.NoDBTestCase): mock_rec_action.assert_called_once_with( self.context, instance, instance_actions.DETACH_SHARE) + + @mock.patch.object(compute_api, 'MIN_COMPUTE_VTPM_LIVE_MIGRATION', 5) + @ddt.data(None, 'host') + def test_reject_legacy_vtpm_live_migration(self, secret_security): + """Test that live migration requests are rejected properly. + + Only certain TPM secret security modes are allowed to request live + migration. + """ + @compute_api.reject_legacy_vtpm_live_migration + def fake_compute_api_method(api_self, context, instance): + pass + + instance = self._create_instance_obj() + instance.flavor.extra_specs = { + 'hw:tpm_version': '1.2', + } + if secret_security: + instance.flavor.extra_specs[ + 'hw:tpm_secret_security'] = secret_security + + with mock.patch( + 'nova.objects.service.Service.get_minimum_version', + return_value=compute_api.MIN_COMPUTE_VTPM_LIVE_MIGRATION): + if secret_security == 'host': + fake_compute_api_method(self.compute_api, self.context, + instance) + else: + self.assertRaises(exception.OperationNotSupportedForVTPM, + fake_compute_api_method, self.compute_api, + self.context, instance) + + @mock.patch.object(compute_api, 'MIN_COMPUTE_VTPM_LIVE_MIGRATION', 5) + def test_reject_legacy_vtpm_live_migration_service_version(self): + """Test live migration request rejection based on service version. + + If a compute is not new enough, live migration will not be allowed. + """ + @compute_api.reject_legacy_vtpm_live_migration + def fake_compute_api_method(api_self, context, instance): + pass + + instance = self._create_instance_obj() + instance.flavor.extra_specs = { + 'hw:tpm_version': '1.2', + 'hw:tpm_secret_security': 'host', + } + + with mock.patch( + 'nova.objects.service.Service.get_minimum_version', + return_value=compute_api.MIN_COMPUTE_VTPM_LIVE_MIGRATION - 1): + self.assertRaises(exception.VTPMOldCompute, + fake_compute_api_method, self.compute_api, + self.context, instance)