diff --git a/src/bosh_azure_cpi/lib/cloud/azure.rb b/src/bosh_azure_cpi/lib/cloud/azure.rb index b91b4960e..c026c0d9a 100644 --- a/src/bosh_azure_cpi/lib/cloud/azure.rb +++ b/src/bosh_azure_cpi/lib/cloud/azure.rb @@ -63,6 +63,7 @@ module AzureCloud; end require 'cloud/azure/models/props_factory' require 'cloud/azure/models/root_disk' require 'cloud/azure/models/security_group' +require 'cloud/azure/models/security_profile' require 'cloud/azure/models/managed_identity' require 'cloud/azure/models/vm_cloud_props' require 'cloud/azure/cloud' diff --git a/src/bosh_azure_cpi/lib/cloud/azure/models/security_profile.rb b/src/bosh_azure_cpi/lib/cloud/azure/models/security_profile.rb new file mode 100644 index 000000000..ab3dea02d --- /dev/null +++ b/src/bosh_azure_cpi/lib/cloud/azure/models/security_profile.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Bosh::AzureCloud + class SecurityProfile + include Helpers + + SECURITY_TYPE_KEY = 'security_type' + SECURE_BOOT_ENABLED_KEY = 'secure_boot_enabled' + + SUPPORTED_SECURITY_TYPES = [SECURITY_TYPE_TRUSTED_LAUNCH, SECURITY_TYPE_STANDARD].freeze + + VTPM_ENABLED = true + + attr_reader :security_type, :secure_boot_enabled + + def initialize(security_profile_config_hash) + cloud_error("'security_profile' must be a Hash, but is a #{security_profile_config_hash.class}.") unless security_profile_config_hash.is_a?(Hash) + + @security_type = security_profile_config_hash.fetch(SECURITY_TYPE_KEY, SECURITY_TYPE_TRUSTED_LAUNCH) + _validate_security_type + + @secure_boot_enabled = security_profile_config_hash.fetch(SECURE_BOOT_ENABLED_KEY, trusted_launch?) + cloud_error("'#{SECURE_BOOT_ENABLED_KEY}' must be a boolean, but is '#{@secure_boot_enabled.inspect}'.") unless [true, false].include?(@secure_boot_enabled) + cloud_error("'#{SECURE_BOOT_ENABLED_KEY}' must be false when '#{SECURITY_TYPE_KEY}' is '#{@security_type}'.") if @secure_boot_enabled && !trusted_launch? + end + + def trusted_launch? + @security_type == SECURITY_TYPE_TRUSTED_LAUNCH + end + + def uefi_settings + return nil unless trusted_launch? + + { + secure_boot_enabled: @secure_boot_enabled, + vtpm_enabled: VTPM_ENABLED + } + end + + private + + def _validate_security_type + return if SUPPORTED_SECURITY_TYPES.include?(@security_type) + + if @security_type == SECURITY_TYPE_CONFIDENTIAL_VM + cloud_error("'#{SECURITY_TYPE_KEY}' '#{SECURITY_TYPE_CONFIDENTIAL_VM}' is not supported by this CPI, yet.") + end + + cloud_error("Invalid '#{SECURITY_TYPE_KEY}' '#{@security_type}'. Supported values: #{SUPPORTED_SECURITY_TYPES.join(', ')}.") + end + end +end diff --git a/src/bosh_azure_cpi/lib/cloud/azure/models/vm_cloud_props.rb b/src/bosh_azure_cpi/lib/cloud/azure/models/vm_cloud_props.rb index 75d4e169b..a2bfb4842 100644 --- a/src/bosh_azure_cpi/lib/cloud/azure/models/vm_cloud_props.rb +++ b/src/bosh_azure_cpi/lib/cloud/azure/models/vm_cloud_props.rb @@ -20,6 +20,7 @@ class VMCloudProps attr_reader :tags attr_reader :capacity_reservation_group attr_reader :capacity_reservation_group_id + attr_reader :security_profile # Below defines are for test purpose # NOTE: The following 3 attr_writer (and their paired readers above) are explicitly separate (instead of using `attr_accessor`) @@ -98,6 +99,9 @@ def initialize(vm_properties, global_azure_config) @capacity_reservation_group = vm_properties['capacity_reservation_group'] @capacity_reservation_group_id = vm_properties['capacity_reservation_group_id'] + + security_profile_hash = vm_properties.fetch('security_profile', nil) + @security_profile = Bosh::AzureCloud::SecurityProfile.new(security_profile_hash) unless security_profile_hash.nil? end private diff --git a/src/bosh_azure_cpi/lib/cloud/azure/restapi/azure_client.rb b/src/bosh_azure_cpi/lib/cloud/azure/restapi/azure_client.rb index 0b9cd0b0b..8a933b4ee 100644 --- a/src/bosh_azure_cpi/lib/cloud/azure/restapi/azure_client.rb +++ b/src/bosh_azure_cpi/lib/cloud/azure/restapi/azure_client.rb @@ -486,6 +486,22 @@ def create_virtual_machine(resource_group_name, vm_params, network_interfaces, a } end + unless vm_params[:security_profile].nil? + security_profile = { + 'securityType' => vm_params[:security_profile][:security_type] + } + + uefi_settings = vm_params[:security_profile][:uefi_settings] + unless uefi_settings.nil? + security_profile['uefiSettings'] = { + 'secureBootEnabled' => uefi_settings[:secure_boot_enabled], + 'vTpmEnabled' => uefi_settings[:vtpm_enabled] + } + end + + vm['properties']['securityProfile'] = security_profile + end + params = { 'validating' => 'true' } diff --git a/src/bosh_azure_cpi/lib/cloud/azure/utils/helpers.rb b/src/bosh_azure_cpi/lib/cloud/azure/utils/helpers.rb index 14d3f0a17..e82476ab6 100644 --- a/src/bosh_azure_cpi/lib/cloud/azure/utils/helpers.rb +++ b/src/bosh_azure_cpi/lib/cloud/azure/utils/helpers.rb @@ -18,7 +18,7 @@ module Helpers ENVIRONMENT_AZUREGERMANCLOUD = 'AzureGermanCloud' API_VERSIONS = { - AZURE_RESOURCE_PROVIDER_COMPUTE => '2021-11-01', + AZURE_RESOURCE_PROVIDER_COMPUTE => '2025-11-01', AZURE_RESOURCE_PROVIDER_COMPUTE_DISK => '2023-04-02', AZURE_RESOURCE_PROVIDER_COMPUTE_SNAPSHOT => '2021-04-01', AZURE_RESOURCE_PROVIDER_COMPUTE_GALLERY => '2023-07-03', @@ -162,6 +162,10 @@ module Helpers MANAGED_IDENTITY_TYPE_SYSTEM_ASSIGNED = 'SystemAssigned' MANAGED_IDENTITY_TYPE_USER_ASSIGNED = 'UserAssigned' + SECURITY_TYPE_TRUSTED_LAUNCH = 'TrustedLaunch' + SECURITY_TYPE_CONFIDENTIAL_VM = 'ConfidentialVM' + SECURITY_TYPE_STANDARD = 'Standard' + # Availability Zones AVAILABILITY_ZONES = %w[1 2 3].freeze diff --git a/src/bosh_azure_cpi/lib/cloud/azure/vms/vm_manager.rb b/src/bosh_azure_cpi/lib/cloud/azure/vms/vm_manager.rb index 1bd28eaac..df10e611c 100644 --- a/src/bosh_azure_cpi/lib/cloud/azure/vms/vm_manager.rb +++ b/src/bosh_azure_cpi/lib/cloud/azure/vms/vm_manager.rb @@ -139,6 +139,13 @@ def create(bosh_vm_meta, location, vm_props, disk_cids, network_configurator, en vm_params[:capacity_reservation_group_id] = vm_props.capacity_reservation_group_id end + unless vm_props.security_profile.nil? + vm_params[:security_profile] = { + security_type: vm_props.security_profile.security_type, + uefi_settings: vm_props.security_profile.uefi_settings + } + end + unless vm_props.managed_identity.nil? vm_params[:identity] = { type: vm_props.managed_identity.type, diff --git a/src/bosh_azure_cpi/spec/spec_helper.rb b/src/bosh_azure_cpi/spec/spec_helper.rb index f55328ddf..d06d0b832 100644 --- a/src/bosh_azure_cpi/spec/spec_helper.rb +++ b/src/bosh_azure_cpi/spec/spec_helper.rb @@ -32,7 +32,7 @@ AZURE_CHINA_API_VERSION = '2015-06-15' AZURE_USGOV_API_VERSION = '2015-06-15' AZURE_GERMAN_API_VERSION = '2015-06-15' -AZURE_RESOURCE_PROVIDER_COMPUTE = '2021-11-01' +AZURE_RESOURCE_PROVIDER_COMPUTE = '2025-11-01' AZURE_RESOURCE_PROVIDER_COMPUTE_DISK = '2023-04-02' AZURE_RESOURCE_PROVIDER_COMPUTE_SNAPSHOT = '2021-04-01' AZURE_RESOURCE_PROVIDER_COMPUTE_GALLERY = '2023-07-03' diff --git a/src/bosh_azure_cpi/spec/unit/azure_client/create_virtual_machine_spec.rb b/src/bosh_azure_cpi/spec/unit/azure_client/create_virtual_machine_spec.rb index 40d8fcf7c..a1b37366c 100644 --- a/src/bosh_azure_cpi/spec/unit/azure_client/create_virtual_machine_spec.rb +++ b/src/bosh_azure_cpi/spec/unit/azure_client/create_virtual_machine_spec.rb @@ -2003,5 +2003,182 @@ end.not_to raise_error end end + + context 'when security_profile is specified' do + let(:vm_params_with_security_profile) do + vm_params_dupped = vm_params.dup + vm_params_dupped.delete(:ephemeral_disk) + vm_params_dupped.delete(:image_uri) + vm_params_dupped[:image_id] = 'g' + vm_params_dupped[:managed] = true + vm_params_dupped[:security_profile] = security_profile + vm_params_dupped + end + + let(:base_request_body) do + { + name: vm_name, + location: 'b', + type: 'Microsoft.Compute/virtualMachines', + tags: { + foo: 'bar' + }, + properties: { + hardwareProfile: { + vmSize: 'c' + }, + osProfile: { + customData: 'f', + computerName: vm_name, + adminUsername: 'd', + linuxConfiguration: { + disablePasswordAuthentication: 'true', + ssh: { + publicKeys: [ + { + path: '/home/d/.ssh/authorized_keys', + keyData: 'e' + } + ] + } + } + }, + networkProfile: { + networkInterfaces: [ + { + id: 'a', + properties: { + primary: true + } + }, + { + id: 'b', + properties: { + primary: false + } + } + ] + }, + storageProfile: { + imageReference: { + id: 'g' + }, + osDisk: { + name: 'h', + createOption: 'FromImage', + caching: 'j', + diskSizeGB: 'k' + } + } + } + } + end + + before do + stub_request(:post, token_uri).to_return( + status: 200, + body: { + 'access_token' => valid_access_token, + 'expires_on' => expires_on + }.to_json, + headers: {} + ) + stub_request(:put, vm_uri).with(body: request_body).to_return( + status: 200, + body: '', + headers: { + 'azure-asyncoperation' => operation_status_link + } + ) + stub_request(:get, operation_status_link).to_return( + status: 200, + body: '{"status":"Succeeded"}', + headers: {} + ) + end + + context 'when Secure Boot is enabled' do + let(:security_profile) do + { + security_type: 'TrustedLaunch', + uefi_settings: { secure_boot_enabled: true, vtpm_enabled: true } + } + end + + let(:request_body) do + base_request_body.deep_merge( + properties: { + securityProfile: { + securityType: 'TrustedLaunch', + uefiSettings: { + secureBootEnabled: true, + vTpmEnabled: true + } + } + } + ) + end + + it 'sends securityProfile with both UEFI settings enabled' do + expect do + azure_client.create_virtual_machine(resource_group, vm_params_with_security_profile, network_interfaces) + end.not_to raise_error + end + end + + context 'when Secure Boot is disabled for custom unsigned kernels or drivers' do + let(:security_profile) do + { + security_type: 'TrustedLaunch', + uefi_settings: { secure_boot_enabled: false, vtpm_enabled: true } + } + end + + let(:request_body) do + base_request_body.deep_merge( + properties: { + securityProfile: { + securityType: 'TrustedLaunch', + uefiSettings: { + secureBootEnabled: false, + vTpmEnabled: true + } + } + } + ) + end + + it 'sends secureBootEnabled false while vTpmEnabled stays true' do + expect do + azure_client.create_virtual_machine(resource_group, vm_params_with_security_profile, network_interfaces) + end.not_to raise_error + end + end + + context 'when the security type is Standard' do + let(:security_profile) do + { + security_type: 'Standard', + uefi_settings: nil + } + end + + let(:request_body) do + base_request_body.deep_merge( + properties: { + securityProfile: { + securityType: 'Standard' + } + } + ) + end + + it 'sends securityType without uefiSettings' do + expect do + azure_client.create_virtual_machine(resource_group, vm_params_with_security_profile, network_interfaces) + end.not_to raise_error + end + end + end end end diff --git a/src/bosh_azure_cpi/spec/unit/models/security_profile_spec.rb b/src/bosh_azure_cpi/spec/unit/models/security_profile_spec.rb new file mode 100644 index 000000000..ab1829113 --- /dev/null +++ b/src/bosh_azure_cpi/spec/unit/models/security_profile_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Bosh::AzureCloud::SecurityProfile do + describe '#initialize' do + context 'when the config is not a Hash' do + it 'raises an error naming the actual type' do + expect do + described_class.new('TrustedLaunch') + end.to raise_error(Bosh::Clouds::CloudError, /'security_profile' must be a Hash, but is a String/) + end + end + + describe 'security_type' do + it 'defaults to TrustedLaunch' do + expect(described_class.new({}).security_type).to eq('TrustedLaunch') + end + + it 'accepts an explicit TrustedLaunch' do + expect(described_class.new('security_type' => 'TrustedLaunch').security_type).to eq('TrustedLaunch') + end + + it 'rejects ConfidentialVM' do + expect do + described_class.new('security_type' => 'ConfidentialVM') + end.to raise_error(Bosh::Clouds::CloudError, /'ConfidentialVM' is not supported by this CPI/) + end + + it 'rejects an unknown value and lists what is supported' do + expect do + described_class.new('security_type' => 'Bogus') + end.to raise_error(Bosh::Clouds::CloudError, /Invalid 'security_type' 'Bogus'\. Supported values: TrustedLaunch, Standard/) + end + + it 'accepts Standard as an explicit opt out of Trusted Launch' do + security_profile = described_class.new('security_type' => 'Standard') + + expect(security_profile.security_type).to eq('Standard') + expect(security_profile.trusted_launch?).to be(false) + end + end + + describe 'secure_boot_enabled' do + it 'defaults to true' do + expect(described_class.new({}).secure_boot_enabled).to be(true) + end + + it 'defaults to false for Standard' do + expect(described_class.new('security_type' => 'Standard').secure_boot_enabled).to be(false) + end + + it 'can be disabled for custom unsigned kernels or drivers' do + expect(described_class.new('secure_boot_enabled' => false).secure_boot_enabled).to be(false) + end + + ['true', 'yes', 1, nil].each do |value| + it "rejects the non-boolean #{value.inspect}" do + expect do + described_class.new('secure_boot_enabled' => value) + end.to raise_error(Bosh::Clouds::CloudError, /'secure_boot_enabled' must be a boolean/) + end + end + end + + describe 'uefi_settings' do + it 'always enables vTPM, even when Secure Boot is disabled' do + expect(described_class.new('secure_boot_enabled' => false).uefi_settings).to eq( + secure_boot_enabled: false, + vtpm_enabled: true + ) + end + + it 'is nil for Standard with Secure Boot explicitly disabled' do + security_profile = described_class.new( + 'security_type' => 'Standard', + 'secure_boot_enabled' => false + ) + + expect(security_profile.uefi_settings).to be_nil + end + end + + describe 'secure_boot_enabled combined with Standard' do + it 'rejects true because Standard cannot enable Secure Boot' do + expect do + described_class.new('security_type' => 'Standard', 'secure_boot_enabled' => true) + end.to raise_error(Bosh::Clouds::CloudError, /'secure_boot_enabled' must be false when 'security_type' is 'Standard'/) + end + end + end +end diff --git a/src/bosh_azure_cpi/spec/unit/models/vm_cloud_props_spec.rb b/src/bosh_azure_cpi/spec/unit/models/vm_cloud_props_spec.rb index 50b8fd913..dbd9bd80b 100644 --- a/src/bosh_azure_cpi/spec/unit/models/vm_cloud_props_spec.rb +++ b/src/bosh_azure_cpi/spec/unit/models/vm_cloud_props_spec.rb @@ -637,5 +637,56 @@ end end + context 'when security_profile is specified' do + it 'exposes a SecurityProfile built from the configured values' do + vm_cloud_props = Bosh::AzureCloud::VMCloudProps.new( + { + 'instance_type' => 'Standard_D2s_v5', + 'security_profile' => { + 'security_type' => 'TrustedLaunch', + 'secure_boot_enabled' => false + } + }, azure_config_managed + ) + + expect(vm_cloud_props.security_profile).to be_a(Bosh::AzureCloud::SecurityProfile) + expect(vm_cloud_props.security_profile.security_type).to eq('TrustedLaunch') + expect(vm_cloud_props.security_profile.uefi_settings).to eq(secure_boot_enabled: false, vtpm_enabled: true) + end + + it 'applies the model defaults for an empty hash' do + vm_cloud_props = Bosh::AzureCloud::VMCloudProps.new( + { + 'instance_type' => 'Standard_D2s_v5', + 'security_profile' => {} + }, azure_config_managed + ) + + expect(vm_cloud_props.security_profile.security_type).to eq('TrustedLaunch') + expect(vm_cloud_props.security_profile.uefi_settings).to eq(secure_boot_enabled: true, vtpm_enabled: true) + end + + it 'propagates validation errors from the model' do + expect do + Bosh::AzureCloud::VMCloudProps.new( + { + 'instance_type' => 'Standard_D2s_v5', + 'security_profile' => { 'security_type' => 'ConfidentialVM' } + }, azure_config_managed + ) + end.to raise_error(Bosh::Clouds::CloudError, /'ConfidentialVM' is not supported by this CPI/) + end + + end + + context 'when security_profile is not specified' do + it 'is nil' do + vm_cloud_props = Bosh::AzureCloud::VMCloudProps.new( + { 'instance_type' => 'Standard_D1' }, azure_config_managed + ) + + expect(vm_cloud_props.security_profile).to be_nil + end + end end end diff --git a/src/bosh_azure_cpi/spec/unit/vm_manager/create/security_profile_spec.rb b/src/bosh_azure_cpi/spec/unit/vm_manager/create/security_profile_spec.rb new file mode 100644 index 000000000..26563c652 --- /dev/null +++ b/src/bosh_azure_cpi/spec/unit/vm_manager/create/security_profile_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'unit/vm_manager/create/shared_stuff' + +describe Bosh::AzureCloud::VMManager do + include_context 'shared stuff for vm manager' + + describe '#create' do + let(:agent_util) { instance_double(Bosh::AzureCloud::BoshAgentUtil) } + let(:network_spec) { {} } + let(:config) { instance_double(Bosh::AzureCloud::Config) } + + let(:props_factory) do + Bosh::AzureCloud::PropsFactory.new( + Bosh::AzureCloud::ConfigFactory.build( + mock_cloud_properties_merge('azure' => { 'use_managed_disks' => true }) + ) + ) + end + + let(:vm_props_with_security_profile) do + props_factory.parse_vm_props( + 'instance_type' => 'Standard_D1', + 'security_profile' => { + 'security_type' => 'TrustedLaunch', + 'secure_boot_enabled' => false + } + ) + end + + before do + allow(vm_manager2).to receive(:_get_stemcell_info).and_return(stemcell_info) + allow(azure_client).to receive(:create_virtual_machine) + end + + def create_vm(vm_props) + vm_manager2.create(bosh_vm_meta, location, vm_props, disk_cids, network_configurator, env, agent_util, network_spec, config) + end + + describe '#security_profile' do + context 'when no security profile is configured' do + let(:vm_props) { props_factory.parse_vm_props('instance_type' => 'Standard_D1') } + + it 'does not send a security profile at all' do + _, vm_params = create_vm(vm_props) + expect(vm_params).not_to have_key(:security_profile) + end + end + + context 'when a security profile is configured in the vm_type' do + it 'passes the resolved settings to the VM params, with vTPM forced on' do + _, vm_params = create_vm(vm_props_with_security_profile) + + expect(vm_params[:security_profile]).to eq( + security_type: 'TrustedLaunch', + uefi_settings: { secure_boot_enabled: false, vtpm_enabled: true } + ) + end + end + + context 'when the security type is Standard' do + let(:vm_props) do + props_factory.parse_vm_props( + 'instance_type' => 'Standard_D1', + 'security_profile' => { + 'security_type' => 'Standard', + 'secure_boot_enabled' => false + } + ) + end + + it 'sends the security type with no UEFI settings' do + _, vm_params = create_vm(vm_props) + + expect(vm_params[:security_profile]).to eq( + security_type: 'Standard', + uefi_settings: nil + ) + end + end + end + end +end