diff --git a/examples/azure-machinedeployment.yaml b/examples/azure-machinedeployment.yaml index abfe62ba8..62365ca78 100644 --- a/examples/azure-machinedeployment.yaml +++ b/examples/azure-machinedeployment.yaml @@ -75,6 +75,21 @@ spec: imageID: "myImageID" assignPublicIP: false securityGroupName: my-security-group + # securityProfile configures security features like TrustedLaunch for the VM. + # securityType is case-sensitive. Valid values: TrustedLaunch, Standard. + # TrustedLaunch requires a Gen2 VM size. Use with Azure Compute Gallery images + # that have SecurityType set to TrustedLaunch. + # securityProfile: + # securityType: TrustedLaunch + # secureBootEnabled: true + # vTpmEnabled: true + # + # To opt out of TrustedLaunch on subscriptions where Azure auto-enables it + # (TrustedLaunchByDefaultPreview registered), set securityType to Standard. + # Requires the UseStandardSecurityType feature flag on the subscription. + # See: https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch#preview-trusted-launch-as-default + # securityProfile: + # securityType: Standard # zones is an optional field and it represents Availability Zones is a high-availability offering # that protects your applications and data from datacenter failures. zones: diff --git a/pkg/cloudprovider/provider/azure/provider.go b/pkg/cloudprovider/provider/azure/provider.go index 143be9a8f..9d2d0ca53 100644 --- a/pkg/cloudprovider/provider/azure/provider.go +++ b/pkg/cloudprovider/provider/azure/provider.go @@ -56,6 +56,10 @@ const ( CapabilityValueTrue = "True" capabilityAcceleratedNetworking = "AcceleratedNetworkingEnabled" + // the legacy Track-1 SDK only exports SecurityTypesTrustedLaunch and SecurityTypesConfidentialVM. + // compute.SecurityTypes is a string alias so casting the literal is safe. + securityTypeStandard = compute.SecurityTypes("Standard") + machineUIDTag = "Machine-UID" finalizerPublicIP = "kubermatic.io/cleanup-azure-public-ip" @@ -108,6 +112,7 @@ type config struct { EnableAcceleratedNetworking *bool EnableBootDiagnostics bool Tags map[string]string + SecurityProfile *compute.SecurityProfile } type azureVM struct { @@ -364,6 +369,8 @@ func (p *provider) getConfig(provSpec clusterv1alpha1.ProviderSpec) (*config, *p c.EnableBootDiagnostics = *rawCfg.EnableBootDiagnostics } + c.SecurityProfile = buildSecurityProfile(rawCfg.SecurityProfile) + return &c, pConfig, nil } @@ -761,6 +768,10 @@ func (p *provider) Create(ctx context.Context, log *zap.SugaredLogger, machine * } } + if config.SecurityProfile != nil { + vmSpec.SecurityProfile = config.SecurityProfile + } + log.Info("Creating machine") if err := data.Update(machine, func(updatedMachine *clusterv1alpha1.Machine) { if !kuberneteshelper.HasFinalizer(updatedMachine, finalizerDisks) { @@ -1023,6 +1034,57 @@ func validateSKUCapabilities(_ context.Context, c *config, sku compute.ResourceS return nil } +// buildSecurityProfile converts the raw provider-spec SecurityProfile into the +// Azure SDK shape. Returns nil when no SecurityProfile is configured so the +// VM CreateOrUpdate call lets Azure apply its subscription defaults. +func buildSecurityProfile(raw *azuretypes.SecurityProfile) *compute.SecurityProfile { + if raw == nil { + return nil + } + + sp := &compute.SecurityProfile{} + if raw.SecurityType != "" { + sp.SecurityType = compute.SecurityTypes(raw.SecurityType) + } + + if raw.SecureBootEnabled != nil || raw.VTpmEnabled != nil { + sp.UefiSettings = &compute.UefiSettings{ + SecureBootEnabled: raw.SecureBootEnabled, + VTpmEnabled: raw.VTpmEnabled, + } + } + + return sp +} + +func validateSecurityProfile(_ context.Context, c *config, sku compute.ResourceSku) error { + if c.SecurityProfile == nil { + return nil + } + + secProfile := c.SecurityProfile + secType := secProfile.SecurityType + + switch secProfile.SecurityType { + case compute.SecurityTypesTrustedLaunch: + if !skuSupportsGen2(sku) { + return fmt.Errorf("securityType %q requires a Gen2 VM size, but %q does not support Gen2", secType, c.VMSize) + } + case securityTypeStandard: + uefiSettings := secProfile.UefiSettings + if uefiSettings != nil && (uefiSettings.SecureBootEnabled != nil || uefiSettings.VTpmEnabled != nil) { + // vTPM can't be used with "Standard" security type + return fmt.Errorf("securityType %q cannot be combined with secureBootEnabled or vTpmEnabled", secType) + } + case "": + return errors.New("securityProfile.securityType must be set when securityProfile is specified") + default: + return fmt.Errorf("unsupported securityType %q; supported values (case-sensitive): TrustedLaunch, Standard", secType) + } + + return nil +} + func (p *provider) Validate(ctx context.Context, log *zap.SugaredLogger, spec clusterv1alpha1.MachineSpec) error { c, providerConfig, err := p.getConfig(spec.ProviderSpec) if err != nil { @@ -1120,6 +1182,10 @@ func (p *provider) Validate(ctx context.Context, log *zap.SugaredLogger, spec cl return fmt.Errorf("failed to validate SKU capabilities: %w", err) } + if err := validateSecurityProfile(ctx, c, sku); err != nil { + return fmt.Errorf("failed to validate security profile: %w", err) + } + _, err = getOSImageReference(c, providerConfig.OperatingSystem) return err } diff --git a/pkg/cloudprovider/provider/azure/provider_test.go b/pkg/cloudprovider/provider/azure/provider_test.go index 535c0e650..2c359d227 100644 --- a/pkg/cloudprovider/provider/azure/provider_test.go +++ b/pkg/cloudprovider/provider/azure/provider_test.go @@ -17,7 +17,21 @@ limitations under the License. package azure import ( + "context" "testing" + + "github.com/Azure/azure-sdk-for-go/profiles/latest/compute/mgmt/compute" + "github.com/Azure/go-autorest/autorest/to" + "github.com/google/go-cmp/cmp" + + azuretypes "k8c.io/machine-controller/sdk/cloudprovider/azure" + + "k8s.io/utils/ptr" +) + +const ( + testVMSizeGen2 = "Standard_D2s_v3" + testVMSizeGen1 = "Standard_A2" ) func TestVMSizeSupportsGen2(t *testing.T) { @@ -33,7 +47,7 @@ func TestVMSizeSupportsGen2(t *testing.T) { }, { name: "Standard_D2s_v3 should support Gen2", - vmSize: "Standard_D2s_v3", + vmSize: testVMSizeGen2, expected: true, }, { @@ -53,7 +67,7 @@ func TestVMSizeSupportsGen2(t *testing.T) { }, { name: "Standard_A2 should not support Gen2", - vmSize: "Standard_A2", + vmSize: testVMSizeGen1, expected: false, }, { @@ -87,3 +101,387 @@ func TestVMSizeSupportsGen2(t *testing.T) { }) } } + +func skuWithHyperVGenerations(generations string) compute.ResourceSku { + return compute.ResourceSku{ + Capabilities: &[]compute.ResourceSkuCapabilities{ + {Name: to.StringPtr("HyperVGenerations"), Value: to.StringPtr(generations)}, + }, + } +} + +func gen2SKU() compute.ResourceSku { return skuWithHyperVGenerations("V1,V2") } +func gen2OnlySKU() compute.ResourceSku { return skuWithHyperVGenerations("V2") } +func gen1OnlySKU() compute.ResourceSku { return skuWithHyperVGenerations("V1") } +func skuWithoutGenCap() compute.ResourceSku { + return compute.ResourceSku{ + Capabilities: &[]compute.ResourceSkuCapabilities{ + {Name: to.StringPtr("vCPUs"), Value: to.StringPtr("2")}, + }, + } +} + +func TestValidateSecurityProfile(t *testing.T) { + tests := []struct { + name string + config *config + sku compute.ResourceSku + expectError bool + }{ + { + name: "nil SecurityProfile passes", + config: &config{VMSize: testVMSizeGen2}, + sku: gen2SKU(), + expectError: false, + }, + { + name: "empty SecurityProfile (zero value) fails with empty securityType error", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{}, + }, + sku: gen2SKU(), + expectError: true, + }, + { + name: "UEFI settings without securityType fails", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(true), + }, + }, + }, + sku: gen2SKU(), + expectError: true, + }, + { + name: "invalid securityType ConfidentialVM fails", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesConfidentialVM, + }, + }, + sku: gen2SKU(), + expectError: true, + }, + { + name: "garbage securityType fails", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypes("Nonsense"), + }, + }, + sku: gen2SKU(), + expectError: true, + }, + { + name: "lowercase trustedlaunch fails (case-sensitive)", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypes("trustedlaunch"), + }, + }, + sku: gen2SKU(), + expectError: true, + }, + { + name: "lowercase standard fails (case-sensitive)", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypes("standard"), + }, + }, + sku: gen2SKU(), + expectError: true, + }, + { + name: "TrustedLaunch on non-Gen2 SKU fails", + config: &config{ + VMSize: testVMSizeGen1, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesTrustedLaunch, + }, + }, + sku: gen1OnlySKU(), + expectError: true, + }, + { + name: "TrustedLaunch on SKU without HyperVGenerations capability fails", + config: &config{ + VMSize: testVMSizeGen1, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesTrustedLaunch, + }, + }, + sku: skuWithoutGenCap(), + expectError: true, + }, + { + name: "TrustedLaunch on Gen2-only SKU passes", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesTrustedLaunch, + }, + }, + sku: gen2OnlySKU(), + expectError: false, + }, + { + name: "TrustedLaunch on Gen2 SKU with no UefiSettings passes (Azure applies defaults)", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesTrustedLaunch, + }, + }, + sku: gen2SKU(), + expectError: false, + }, + { + name: "TrustedLaunch with only secureBootEnabled set passes", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesTrustedLaunch, + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(true), + }, + }, + }, + sku: gen2SKU(), + expectError: false, + }, + { + name: "TrustedLaunch with only vTpmEnabled set passes", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesTrustedLaunch, + UefiSettings: &compute.UefiSettings{ + VTpmEnabled: ptr.To(true), + }, + }, + }, + sku: gen2SKU(), + expectError: false, + }, + { + name: "TrustedLaunch with secureBoot and vTpm both false passes", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesTrustedLaunch, + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(false), + VTpmEnabled: ptr.To(false), + }, + }, + }, + sku: gen2SKU(), + expectError: false, + }, + { + name: "TrustedLaunch with both UEFI settings true passes", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypesTrustedLaunch, + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(true), + VTpmEnabled: ptr.To(true), + }, + }, + }, + sku: gen2SKU(), + expectError: false, + }, + { + name: "Standard on Gen2 SKU passes", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: securityTypeStandard, + }, + }, + sku: gen2SKU(), + expectError: false, + }, + { + name: "Standard on Gen1 SKU passes", + config: &config{ + VMSize: testVMSizeGen1, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: securityTypeStandard, + }, + }, + sku: gen1OnlySKU(), + expectError: false, + }, + { + name: "Standard with empty UefiSettings (both nil) passes", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: securityTypeStandard, + UefiSettings: &compute.UefiSettings{}, + }, + }, + sku: gen2SKU(), + expectError: false, + }, + { + name: "Standard with secureBootEnabled fails", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: securityTypeStandard, + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(true), + }, + }, + }, + sku: gen2SKU(), + expectError: true, + }, + { + name: "Standard with vTpmEnabled fails", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: securityTypeStandard, + UefiSettings: &compute.UefiSettings{ + VTpmEnabled: ptr.To(true), + }, + }, + }, + sku: gen2SKU(), + expectError: true, + }, + { + name: "Standard with secureBootEnabled false fails (any non-nil pointer)", + config: &config{ + VMSize: testVMSizeGen2, + SecurityProfile: &compute.SecurityProfile{ + SecurityType: securityTypeStandard, + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(false), + }, + }, + }, + sku: gen2SKU(), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSecurityProfile(context.Background(), tt.config, tt.sku) + if (err != nil) != tt.expectError { + t.Errorf("validateSecurityProfile() error = %v, expectError %v", err, tt.expectError) + } + }) + } +} + +func TestBuildSecurityProfile(t *testing.T) { + tests := []struct { + name string + raw *azuretypes.SecurityProfile + expected *compute.SecurityProfile + }{ + { + name: "nil raw returns nil", + raw: nil, + expected: nil, + }, + { + name: "empty raw returns empty SecurityProfile (caught by validator)", + raw: &azuretypes.SecurityProfile{}, + expected: &compute.SecurityProfile{}, + }, + { + name: "only securityType set", + raw: &azuretypes.SecurityProfile{SecurityType: "TrustedLaunch"}, + expected: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypes("TrustedLaunch"), + }, + }, + { + name: "only secureBootEnabled set (no securityType) constructs UefiSettings, validator rejects later", + raw: &azuretypes.SecurityProfile{SecureBootEnabled: ptr.To(true)}, + expected: &compute.SecurityProfile{ + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(true), + }, + }, + }, + { + name: "only vTpmEnabled set constructs UefiSettings", + raw: &azuretypes.SecurityProfile{VTpmEnabled: ptr.To(true)}, + expected: &compute.SecurityProfile{ + UefiSettings: &compute.UefiSettings{ + VTpmEnabled: ptr.To(true), + }, + }, + }, + { + name: "both UEFI flags set, no securityType", + raw: &azuretypes.SecurityProfile{ + SecureBootEnabled: ptr.To(true), + VTpmEnabled: ptr.To(false), + }, + expected: &compute.SecurityProfile{ + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(true), + VTpmEnabled: ptr.To(false), + }, + }, + }, + { + name: "fully populated TrustedLaunch", + raw: &azuretypes.SecurityProfile{ + SecurityType: "TrustedLaunch", + SecureBootEnabled: ptr.To(true), + VTpmEnabled: ptr.To(true), + }, + expected: &compute.SecurityProfile{ + SecurityType: compute.SecurityTypes("TrustedLaunch"), + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(true), + VTpmEnabled: ptr.To(true), + }, + }, + }, + { + name: "Standard with both UEFI flags (validator will reject) is still constructed", + raw: &azuretypes.SecurityProfile{ + SecurityType: "Standard", + SecureBootEnabled: ptr.To(false), + VTpmEnabled: ptr.To(false), + }, + expected: &compute.SecurityProfile{ + SecurityType: securityTypeStandard, + UefiSettings: &compute.UefiSettings{ + SecureBootEnabled: ptr.To(false), + VTpmEnabled: ptr.To(false), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildSecurityProfile(tt.raw) + if diff := cmp.Diff(tt.expected, got); diff != "" { + t.Errorf("buildSecurityProfile() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/sdk/cloudprovider/azure/types.go b/sdk/cloudprovider/azure/types.go index ae3266ab1..ebdb4e670 100644 --- a/sdk/cloudprovider/azure/types.go +++ b/sdk/cloudprovider/azure/types.go @@ -21,6 +21,13 @@ import ( "k8c.io/machine-controller/sdk/providerconfig" ) +// SecurityProfile specifies security settings like TrustedLaunch for the VM. +type SecurityProfile struct { + SecurityType string `json:"securityType,omitempty"` + SecureBootEnabled *bool `json:"secureBootEnabled,omitempty"` + VTpmEnabled *bool `json:"vTpmEnabled,omitempty"` +} + // RawConfig is a direct representation of an Azure machine object's configuration. type RawConfig struct { SubscriptionID providerconfig.ConfigVarString `json:"subscriptionID,omitempty"` @@ -53,6 +60,8 @@ type RawConfig struct { AssignPublicIP providerconfig.ConfigVarBool `json:"assignPublicIP"` PublicIPSKU *string `json:"publicIPSKU,omitempty"` Tags map[string]string `json:"tags,omitempty"` + + SecurityProfile *SecurityProfile `json:"securityProfile,omitempty"` } // ImagePlan contains azure OS Plan fields for the marketplace images.