diff --git a/nova/tests/unit/virt/hyperv/test_migrationops.py b/nova/tests/unit/virt/hyperv/test_migrationops.py index ca90879b9f..0973e509c9 100644 --- a/nova/tests/unit/virt/hyperv/test_migrationops.py +++ b/nova/tests/unit/virt/hyperv/test_migrationops.py @@ -272,7 +272,8 @@ class MigrationOpsTestCase(test_base.HyperVBaseTestCase): mock_instance.uuid, test.MatchType(objects.ImageMeta)) self._migrationops._vmops.create_instance.assert_called_once_with( mock_instance, mock.sentinel.network_info, root_device, - block_device_info, get_image_vm_gen.return_value) + block_device_info, get_image_vm_gen.return_value, + mock_image.return_value) mock_check_attach_config_drive.assert_called_once_with( mock_instance, get_image_vm_gen.return_value) self._migrationops._vmops.power_on.assert_called_once_with( @@ -433,7 +434,8 @@ class MigrationOpsTestCase(test_base.HyperVBaseTestCase): mock.sentinel.image_meta) self._migrationops._vmops.create_instance.assert_called_once_with( mock_instance, mock.sentinel.network_info, root_device, - block_device_info, get_image_vm_gen.return_value) + block_device_info, get_image_vm_gen.return_value, + mock.sentinel.image_meta) mock_check_attach_config_drive.assert_called_once_with( mock_instance, get_image_vm_gen.return_value) self._migrationops._vmops.power_on.assert_called_once_with( diff --git a/nova/tests/unit/virt/hyperv/test_vmops.py b/nova/tests/unit/virt/hyperv/test_vmops.py index cb9bec0370..893c847fc6 100644 --- a/nova/tests/unit/virt/hyperv/test_vmops.py +++ b/nova/tests/unit/virt/hyperv/test_vmops.py @@ -26,6 +26,7 @@ from oslo_utils import units from nova.compute import vm_states from nova import exception from nova import objects +from nova.objects import fields from nova.objects import flavor as flavor_obj from nova.tests.unit import fake_instance from nova.tests.unit.objects import test_flavor @@ -395,11 +396,12 @@ class VMOpsTestCase(test_base.HyperVBaseTestCase): mock_configdrive_required, mock_create_config_drive, mock_attach_config_drive, mock_power_on, mock_destroy, exists, - configdrive_required, fail): + configdrive_required, fail, + fake_vm_gen=constants.VM_GEN_2): mock_instance = fake_instance.fake_instance_obj(self.context) mock_image_meta = mock.MagicMock() root_device_info = mock.sentinel.ROOT_DEV_INFO - fake_vm_gen = mock_get_image_vm_gen.return_value + mock_get_image_vm_gen.return_value = fake_vm_gen fake_config_drive_path = mock_create_config_drive.return_value block_device_info = {'ephemerals': [], 'root_disk': root_device_info} @@ -439,7 +441,7 @@ class VMOpsTestCase(test_base.HyperVBaseTestCase): mock_image_meta) mock_create_instance.assert_called_once_with( mock_instance, mock.sentinel.INFO, root_device_info, - block_device_info, fake_vm_gen) + block_device_info, fake_vm_gen, mock_image_meta) mock_save_device_metadata.assert_called_once_with( self.context, mock_instance, block_device_info) mock_configdrive_required.assert_called_once_with(mock_instance) @@ -474,6 +476,8 @@ class VMOpsTestCase(test_base.HyperVBaseTestCase): [mock.sentinel.FILE], mock.sentinel.PASSWORD, mock.sentinel.INFO, mock.sentinel.DEV_INFO) + @mock.patch.object(vmops.VMOps, '_requires_secure_boot') + @mock.patch.object(vmops.VMOps, '_requires_certificate') @mock.patch('nova.virt.hyperv.volumeops.VolumeOps' '.attach_volumes') @mock.patch.object(vmops.VMOps, '_set_instance_disk_qos_specs') @@ -487,6 +491,8 @@ class VMOpsTestCase(test_base.HyperVBaseTestCase): mock_create_pipes, mock_set_qos_specs, mock_attach_volumes, + mock_requires_certificate, + mock_requires_secure_boot, enable_instance_metrics, vm_gen=constants.VM_GEN_1): mock_vif_driver = mock.MagicMock() @@ -499,6 +505,7 @@ class VMOpsTestCase(test_base.HyperVBaseTestCase): 'address': mock.sentinel.ADDRESS} mock_instance = fake_instance.fake_instance_obj(self.context) instance_path = os.path.join(CONF.instances_path, mock_instance.name) + mock_requires_secure_boot.return_value = True flavor = flavor_obj.Flavor(**test_flavor.fake_flavor) mock_instance.flavor = flavor @@ -507,7 +514,8 @@ class VMOpsTestCase(test_base.HyperVBaseTestCase): network_info=[fake_network_info], root_device=root_device_info, block_device_info=block_device_info, - vm_gen=vm_gen) + vm_gen=vm_gen, + image_meta=mock.sentinel.image_meta) self._vmops._vmutils.create_vm.assert_called_once_with( mock_instance.name, mock_instance.flavor.memory_mb, mock_instance.flavor.vcpus, CONF.hyperv.limit_cpu_features, @@ -533,6 +541,14 @@ class VMOpsTestCase(test_base.HyperVBaseTestCase): if enable_instance_metrics: mock_enable.assert_called_once_with(mock_instance.name) mock_set_qos_specs.assert_called_once_with(mock_instance) + mock_requires_secure_boot.assert_called_once_with( + mock_instance, mock.sentinel.image_meta, vm_gen) + mock_requires_certificate.assert_called_once_with( + mock.sentinel.image_meta) + enable_secure_boot = self._vmops._vmutils.enable_secure_boot + enable_secure_boot.assert_called_once_with( + mock_instance.name, + msft_ca_required=mock_requires_certificate.return_value) def test_create_instance(self): self._test_create_instance(enable_instance_metrics=True) @@ -655,6 +671,77 @@ class VMOpsTestCase(test_base.HyperVBaseTestCase): mock.sentinel.instance_id, constants.VM_GEN_2, mock.sentinel.FAKE_PATH) + def _check_requires_certificate(self, os_type): + mock_image_meta = mock.MagicMock() + mock_image_meta.properties = {'os_type': os_type} + + expected_result = os_type == fields.OSType.LINUX + result = self._vmops._requires_certificate(mock_image_meta) + self.assertEqual(expected_result, result) + + def test_requires_certificate_windows(self): + self._check_requires_certificate(os_type=fields.OSType.WINDOWS) + + def test_requires_certificate_linux(self): + self._check_requires_certificate(os_type=fields.OSType.LINUX) + + def _check_requires_secure_boot( + self, image_prop_os_type=fields.OSType.LINUX, + image_prop_secure_boot=fields.SecureBoot.REQUIRED, + flavor_secure_boot=fields.SecureBoot.REQUIRED, + vm_gen=constants.VM_GEN_2, expected_exception=True): + mock_instance = fake_instance.fake_instance_obj(self.context) + if flavor_secure_boot: + mock_instance.flavor.extra_specs = { + constants.FLAVOR_SPEC_SECURE_BOOT: flavor_secure_boot} + mock_image_meta = mock.MagicMock() + mock_image_meta.properties = {'os_type': image_prop_os_type} + if image_prop_secure_boot: + mock_image_meta.properties['os_secure_boot'] = ( + image_prop_secure_boot) + + if expected_exception: + self.assertRaises(exception.InstanceUnacceptable, + self._vmops._requires_secure_boot, + mock_instance, mock_image_meta, vm_gen) + else: + result = self._vmops._requires_secure_boot(mock_instance, + mock_image_meta, + vm_gen) + + requires_sb = fields.SecureBoot.REQUIRED in [ + flavor_secure_boot, image_prop_secure_boot] + self.assertEqual(requires_sb, result) + + def test_requires_secure_boot_ok(self): + self._check_requires_secure_boot( + expected_exception=False) + + def test_requires_secure_boot_image_img_prop_none(self): + self._check_requires_secure_boot( + image_prop_secure_boot=None, + expected_exception=False) + + def test_requires_secure_boot_image_extra_spec_none(self): + self._check_requires_secure_boot( + flavor_secure_boot=None, + expected_exception=False) + + def test_requires_secure_boot_flavor_no_os_type(self): + self._check_requires_secure_boot( + image_prop_os_type=None) + + def test_requires_secure_boot_flavor_disabled(self): + self._check_requires_secure_boot( + flavor_secure_boot=fields.SecureBoot.DISABLED) + + def test_requires_secure_boot_image_disabled(self): + self._check_requires_secure_boot( + image_prop_secure_boot=fields.SecureBoot.DISABLED) + + def test_requires_secure_boot_generation_1(self): + self._check_requires_secure_boot(vm_gen=constants.VM_GEN_1) + @mock.patch('nova.api.metadata.base.InstanceMetadata') @mock.patch('nova.virt.configdrive.ConfigDriveBuilder') @mock.patch('nova.utils.execute') diff --git a/nova/virt/hyperv/constants.py b/nova/virt/hyperv/constants.py index c636fafe37..e971679450 100644 --- a/nova/virt/hyperv/constants.py +++ b/nova/virt/hyperv/constants.py @@ -65,6 +65,7 @@ HOST_POWER_ACTION_SHUTDOWN = "shutdown" HOST_POWER_ACTION_REBOOT = "reboot" HOST_POWER_ACTION_STARTUP = "startup" +FLAVOR_SPEC_SECURE_BOOT = "os:secure_boot" IMAGE_PROP_VM_GEN_1 = "hyperv-gen1" IMAGE_PROP_VM_GEN_2 = "hyperv-gen2" diff --git a/nova/virt/hyperv/migrationops.py b/nova/virt/hyperv/migrationops.py index 50fcca828b..3445b712e0 100644 --- a/nova/virt/hyperv/migrationops.py +++ b/nova/virt/hyperv/migrationops.py @@ -184,7 +184,7 @@ class MigrationOps(object): self._check_ephemeral_disks(instance, ephemerals) self._vmops.create_instance(instance, network_info, root_device, - block_device_info, vm_gen) + block_device_info, vm_gen, image_meta) self._check_and_attach_config_drive(instance, vm_gen) @@ -293,7 +293,7 @@ class MigrationOps(object): self._check_ephemeral_disks(instance, ephemerals, resize_instance) self._vmops.create_instance(instance, network_info, root_device, - block_device_info, vm_gen) + block_device_info, vm_gen, image_meta) self._check_and_attach_config_drive(instance, vm_gen) diff --git a/nova/virt/hyperv/vmops.py b/nova/virt/hyperv/vmops.py index f1fc289b70..71f31e70a7 100644 --- a/nova/virt/hyperv/vmops.py +++ b/nova/virt/hyperv/vmops.py @@ -40,6 +40,7 @@ import nova.conf from nova import exception from nova.i18n import _, _LI, _LE, _LW from nova import objects +from nova.objects import fields from nova import utils from nova.virt import configdrive from nova.virt import hardware @@ -291,7 +292,7 @@ class VMOps(object): try: self.create_instance(instance, network_info, root_device, - block_device_info, vm_gen) + block_device_info, vm_gen, image_meta) self._save_device_metadata(context, instance, block_device_info) if configdrive.required_by(instance): @@ -309,9 +310,11 @@ class VMOps(object): self.destroy(instance) def create_instance(self, instance, network_info, root_device, - block_device_info, vm_gen): + block_device_info, vm_gen, image_meta): instance_name = instance.name instance_path = os.path.join(CONF.instances_path, instance_name) + secure_boot_enabled = self._requires_secure_boot(instance, image_meta, + vm_gen) self._vmutils.create_vm(instance_name, instance.flavor.memory_mb, @@ -352,6 +355,11 @@ class VMOps(object): self._set_instance_disk_qos_specs(instance) + if secure_boot_enabled: + certificate_required = self._requires_certificate(image_meta) + self._vmutils.enable_secure_boot( + instance.name, msft_ca_required=certificate_required) + def _configure_remotefx(self, instance, vm_gen): extra_specs = instance.flavor.extra_specs remotefx_max_resolution = extra_specs.get( @@ -443,6 +451,62 @@ class VMOps(object): raise exception.InstanceUnacceptable(instance_id=instance_id, reason=reason) + def _requires_certificate(self, image_meta): + os_type = image_meta.properties.get('os_type') + if os_type == fields.OSType.WINDOWS: + return False + return True + + def _requires_secure_boot(self, instance, image_meta, vm_gen): + """Checks whether the given instance requires Secure Boot. + + Secure Boot feature will be enabled by setting the "os_secure_boot" + image property or the "os:secure_boot" flavor extra spec to required. + + :raises exception.InstanceUnacceptable: if the given image_meta has + no os_type property set, or if the image property value and the + flavor extra spec value are conflicting, or if Secure Boot is + required, but the instance's VM generation is 1. + """ + os_type = image_meta.properties.get('os_type') + if not os_type: + reason = _('For secure boot, os_type must be specified in image ' + 'properties.') + raise exception.InstanceUnacceptable(instance_id=instance.uuid, + reason=reason) + + img_secure_boot = image_meta.properties.get('os_secure_boot') + flavor_secure_boot = instance.flavor.extra_specs.get( + constants.FLAVOR_SPEC_SECURE_BOOT) + + requires_sb = False + conflicting_values = False + + if flavor_secure_boot == fields.SecureBoot.REQUIRED: + requires_sb = True + if img_secure_boot == fields.SecureBoot.DISABLED: + conflicting_values = True + elif img_secure_boot == fields.SecureBoot.REQUIRED: + requires_sb = True + if flavor_secure_boot == fields.SecureBoot.DISABLED: + conflicting_values = True + + if conflicting_values: + reason = _( + "Conflicting image metadata property and flavor extra_specs " + "values: os_secure_boot (%(image_secure_boot)s) / " + "os:secure_boot (%(flavor_secure_boot)s)") % { + 'image_secure_boot': img_secure_boot, + 'flavor_secure_boot': flavor_secure_boot} + raise exception.InstanceUnacceptable(instance_id=instance.uuid, + reason=reason) + + if vm_gen != constants.VM_GEN_2 and requires_sb: + reason = _('Secure boot requires generation 2 VM.') + raise exception.InstanceUnacceptable(instance_id=instance.uuid, + reason=reason) + return requires_sb + def _create_config_drive(self, context, instance, injected_files, admin_password, network_info, rescue=False): if CONF.config_drive_format != 'iso9660': diff --git a/releasenotes/notes/hyperv-uefi-secure-boot-a2a617ac2c313afd.yaml b/releasenotes/notes/hyperv-uefi-secure-boot-a2a617ac2c313afd.yaml new file mode 100644 index 0000000000..72f7380809 --- /dev/null +++ b/releasenotes/notes/hyperv-uefi-secure-boot-a2a617ac2c313afd.yaml @@ -0,0 +1,20 @@ +--- +features: + - | + Added support for Hyper-V VMs with UEFI Secure Boot enabled. + In order to create such VMs, there are a couple of things to consider: + + * Images should be prepared for Generation 2 VMs. The image property + "hw_machine_type=hyperv-gen2" is mandatory. + * The guest OS type must be specified in order to properly spawn the VMs. + It can be specifed through the image property "os_type", and the + acceptable values are "windows" or "linux". + * The UEFI Secure Boot feature can be requested through the image property + "os_secure_boot" (acceptable values: "disabled", "optional", "required") + or flavor extra spec "os:secure_boot" (acceptable values: "disabled", + "required"). The flavor extra spec will take precedence. If the image + property and the flavor extra spec values are conflicting, then an + exception is raised. + * This feature is supported on Windows / Hyper-V Server 2012 R2 for + Windows guests, and Windows / Hyper-V Server 2016 for both + Windows and Linux guests.