diff --git a/src/Migrate/Migrate.Autorest/custom/Helper/AzLocalCommonHelper.ps1 b/src/Migrate/Migrate.Autorest/custom/Helper/AzLocalCommonHelper.ps1 index 37fc54eba40d..aa0e44aef14f 100644 --- a/src/Migrate/Migrate.Autorest/custom/Helper/AzLocalCommonHelper.ps1 +++ b/src/Migrate/Migrate.Autorest/custom/Helper/AzLocalCommonHelper.ps1 @@ -609,4 +609,48 @@ function New-OffAzureResourceNotFoundException { ) return "'$Scenario' '$Name' not found in resource group '$ResourceGroupName' and site '$SiteName'." +} + +function Get-AzMigrateSourceSecureBootState { + [Microsoft.Azure.PowerShell.Cmdlets.Migrate.DoNotExportAttribute()] + param( + [Parameter(Mandatory)] + [string] + ${MachineId} + ) + + # Returns $true/$false, or $null when the state cannot be determined. Callers must treat $null + # as unknown and fall through to the service rather than blocking the migration. + $uri = "{0}?api-version={1}" -f $MachineId, $ApiVersions.OffAzureMachineRead + + try { + $response = Invoke-AzRestMethod -Path $uri -Method GET -ErrorAction Stop + } + catch { + Write-Verbose "Could not read Secure Boot state from '$MachineId': $($_.Exception.Message)" + return $null + } + + if ($null -eq $response -or $response.StatusCode -ne 200) { + Write-Verbose "Could not read Secure Boot state from '$MachineId'. Status code: $($response.StatusCode)." + return $null + } + + try { + $properties = ($response.Content | ConvertFrom-Json).properties + } + catch { + Write-Verbose "Could not parse the discovered machine response for '$MachineId'." + return $null + } + + # Absent on older appliance versions and on clouds still serving the GA contract. + if ($null -eq $properties -or + 'secureBootEnabled' -notin $properties.PSObject.Properties.Name -or + $null -eq $properties.secureBootEnabled) { + Write-Verbose "Discovered machine '$MachineId' does not report Secure Boot state." + return $null + } + + return [bool]$properties.secureBootEnabled } \ No newline at end of file diff --git a/src/Migrate/Migrate.Autorest/custom/Helper/AzLocalCommonSettings.ps1 b/src/Migrate/Migrate.Autorest/custom/Helper/AzLocalCommonSettings.ps1 index 1177e465033f..4cac0d6ab1ab 100644 --- a/src/Migrate/Migrate.Autorest/custom/Helper/AzLocalCommonSettings.ps1 +++ b/src/Migrate/Migrate.Autorest/custom/Helper/AzLocalCommonSettings.ps1 @@ -28,11 +28,16 @@ $ApiVersions = @{ HyperVSites = "2020-01-01"; ProtectedItem = "2021-02-16-preview"; AzLocal = "2021-09-01-preview"; + # The module is generated against OffAzure 2020-01-01, which has no secureBootEnabled on its + # machine models. Read that one field at the newer version instead of regenerating, since + # every OffAzure operationId was renamed after 2020-01-01. + OffAzureMachineRead = "2024-12-01-preview"; } # Role definition GUIDs for storage account creation $RoleDefinitionIds = @{ ContributorId = "b24988ac-6180-42a0-ab88-20f7382dd24c"; + StorageAccountContributorId = "17d1049b-9a84-46fb-8f53-869881c3d3ab"; StorageBlobDataContributorId = "ba92f5b4-2d11-453d-a403-e96b0029c9fe"; } @@ -108,6 +113,20 @@ $OsTypes = @{ OtherGuestFamily = "otherguestfamily"; } +# User-facing security types on the Local replication cmdlets. +$TargetVMSecurityTypes = @{ + Standard = "Standard"; + TrustedLaunch = "TrustedLaunch"; +} + +# Wire values for the service 'securityOption' field. 'EnablevTPM' is omitted deliberately: +# it means vTPM without Secure Boot, which the service always rejects (error 2109020). +$SecurityOptions = @{ + None = "None"; + SecureBootEnabled = "SecureBootEnabled"; + TrustedLaunch = "TrustedLaunch"; +} + $VmReplicationValidationMessage = "Replication could not be initiated. Please ensure the necessary changes are made, and allow up to 30 minutes before re-trying." $VmReplicationValidationMessages = @{ VmPoweredOff = "The VM is currently powered off. $VmReplicationValidationMessage"; diff --git a/src/Migrate/Migrate.Autorest/custom/Initialize-AzMigrateLocalReplicationInfrastructure.ps1 b/src/Migrate/Migrate.Autorest/custom/Initialize-AzMigrateLocalReplicationInfrastructure.ps1 index 6b91290a08c1..def6ceb3ae32 100644 --- a/src/Migrate/Migrate.Autorest/custom/Initialize-AzMigrateLocalReplicationInfrastructure.ps1 +++ b/src/Migrate/Migrate.Autorest/custom/Initialize-AzMigrateLocalReplicationInfrastructure.ps1 @@ -785,7 +785,7 @@ function Initialize-AzMigrateLocalReplicationInfrastructure { } $params = @{ - contributorRoleDefId = [System.Guid]::parse($RoleDefinitionIds.ContributorId); + storageAccountContributorRoleDefId = [System.Guid]::parse($RoleDefinitionIds.StorageAccountContributorId); storageBlobDataContributorRoleDefId = [System.Guid]::parse($RoleDefinitionIds.StorageBlobDataContributorId); sourceAppAadId = $sourceDra.Property.ResourceAccessIdentity.ObjectId; targetAppAadId = $targetDra.Property.ResourceAccessIdentity.ObjectId; @@ -796,17 +796,17 @@ function Initialize-AzMigrateLocalReplicationInfrastructure { { $params.vaultIdentityAadId = $replicationVault.IdentityPrincipalId - # Grant vault Identity Aad access to Cache Storage Account as "Contributor" + # Grant vault Identity Aad access to Cache Storage Account as "Storage Account Contributor" $hasAadAppAccess = Get-AzRoleAssignment ` -ObjectId $params.vaultIdentityAadId ` - -RoleDefinitionId $params.contributorRoleDefId ` + -RoleDefinitionId $params.storageAccountContributorRoleDefId ` -Scope $cacheStorageAccount.Id ` -ErrorVariable notPresent ` -ErrorAction SilentlyContinue if ($null -eq $hasAadAppAccess) { New-AzRoleAssignment ` -ObjectId $params.vaultIdentityAadId ` - -RoleDefinitionId $params.contributorRoleDefId ` + -RoleDefinitionId $params.storageAccountContributorRoleDefId ` -Scope $cacheStorageAccount.Id | Out-Null } @@ -825,17 +825,17 @@ function Initialize-AzMigrateLocalReplicationInfrastructure { } } - # Grant Source Dra AAD App access to Cache Storage Account as "Contributor" + # Grant Source Dra AAD App access to Cache Storage Account as "Storage Account Contributor" $hasAadAppAccess = Get-AzRoleAssignment ` -ObjectId $params.sourceAppAadId ` - -RoleDefinitionId $params.contributorRoleDefId ` + -RoleDefinitionId $params.storageAccountContributorRoleDefId ` -Scope $cacheStorageAccount.Id ` -ErrorVariable notPresent ` -ErrorAction SilentlyContinue if ($null -eq $hasAadAppAccess) { New-AzRoleAssignment ` -ObjectId $params.sourceAppAadId ` - -RoleDefinitionId $params.contributorRoleDefId ` + -RoleDefinitionId $params.storageAccountContributorRoleDefId ` -Scope $cacheStorageAccount.Id | Out-Null } @@ -853,17 +853,17 @@ function Initialize-AzMigrateLocalReplicationInfrastructure { -Scope $cacheStorageAccount.Id | Out-Null } - # Grant Target Dra AAD App access to Cache Storage Account as "Contributor" + # Grant Target Dra AAD App access to Cache Storage Account as "Storage Account Contributor" $hasAadAppAccess = Get-AzRoleAssignment ` -ObjectId $params.targetAppAadId ` - -RoleDefinitionId $params.contributorRoleDefId ` + -RoleDefinitionId $params.storageAccountContributorRoleDefId ` -Scope $cacheStorageAccount.Id ` -ErrorVariable notPresent ` -ErrorAction SilentlyContinue if ($null -eq $hasAadAppAccess) { New-AzRoleAssignment ` -ObjectId $params.targetAppAadId ` - -RoleDefinitionId $params.contributorRoleDefId ` + -RoleDefinitionId $params.storageAccountContributorRoleDefId ` -Scope $cacheStorageAccount.Id | Out-Null } @@ -884,10 +884,10 @@ function Initialize-AzMigrateLocalReplicationInfrastructure { # Give time for role assignments to be created. Times out after 2min $rsaPermissionGranted = $false for ($i = 0; $i -lt 3; $i++) { - # Check Source Dra AAD App access to Cache Storage Account as "Contributor" + # Check Source Dra AAD App access to Cache Storage Account as "Storage Account Contributor" $hasAadAppAccess = Get-AzRoleAssignment ` -ObjectId $params.sourceAppAadId ` - -RoleDefinitionId $params.contributorRoleDefId ` + -RoleDefinitionId $params.storageAccountContributorRoleDefId ` -Scope $cacheStorageAccount.Id ` -ErrorVariable notPresent ` -ErrorAction SilentlyContinue @@ -902,10 +902,10 @@ function Initialize-AzMigrateLocalReplicationInfrastructure { -ErrorAction SilentlyContinue $rsaPermissionGranted = $rsaPermissionGranted -and ($null -ne $hasAadAppAccess) - # Check Target Dra AAD App access to Cache Storage Account as "Contributor" + # Check Target Dra AAD App access to Cache Storage Account as "Storage Account Contributor" $hasAadAppAccess = Get-AzRoleAssignment ` -ObjectId $params.targetAppAadId ` - -RoleDefinitionId $params.contributorRoleDefId ` + -RoleDefinitionId $params.storageAccountContributorRoleDefId ` -Scope $cacheStorageAccount.Id ` -ErrorVariable notPresent ` -ErrorAction SilentlyContinue diff --git a/src/Migrate/Migrate.Autorest/custom/New-AzMigrateLocalServerReplication.ps1 b/src/Migrate/Migrate.Autorest/custom/New-AzMigrateLocalServerReplication.ps1 index 478d44c052f3..85497a5ada33 100644 --- a/src/Migrate/Migrate.Autorest/custom/New-AzMigrateLocalServerReplication.ps1 +++ b/src/Migrate/Migrate.Autorest/custom/New-AzMigrateLocalServerReplication.ps1 @@ -73,6 +73,22 @@ function New-AzMigrateLocalServerReplication { # Specifies whether to migrate the server as an Azure Arc-enabled VM. When set to 'true', an Azure Arc-enabled machine resource with the same name as -TargetVMName must already exist in the resource group specified by -TargetResourceGroupId. ${MigrateAsArcVM}, + [Parameter()] + [ValidateSet("Standard", "TrustedLaunch")] + [ArgumentCompleter( { "Standard", "TrustedLaunch" })] + [Microsoft.Azure.PowerShell.Cmdlets.Migrate.Category('Path')] + [System.String] + # Specifies the security type of the target VM. 'TrustedLaunch' enables Secure Boot and vTPM, and implies -EnableSecureBoot 'true'. Only supported for Generation 2 target VMs. + ${TargetVMSecurityOption}, + + [Parameter()] + [ValidateSet("true" , "false")] + [ArgumentCompleter( { "true" , "false" })] + [Microsoft.Azure.PowerShell.Cmdlets.Migrate.Category('Path')] + [System.String] + # Specifies whether Secure Boot is enabled on the target VM. Only supported for Generation 2 target VMs. When omitted, the target VM inherits the Secure Boot setting of the source server. + ${EnableSecureBoot}, + [Parameter()] [Microsoft.Azure.PowerShell.Cmdlets.Migrate.Category('Path')] [System.Int64] @@ -182,6 +198,18 @@ function New-AzMigrateLocalServerReplication { $helperPath = [System.IO.Path]::Combine($PSScriptRoot, "Helper", "AzLocalCommonHelper.ps1") Import-Module $helperPath + $HasTargetVMSecurityOption = $PSBoundParameters.ContainsKey('TargetVMSecurityOption') + $HasEnableSecureBoot = $PSBoundParameters.ContainsKey('EnableSecureBoot') + if ($HasEnableSecureBoot) { + $secureBootEnabled = [System.Convert]::ToBoolean($EnableSecureBoot) + } + + # Purely a contradiction between parameters, so reject it before the module and service checks. + if ($HasTargetVMSecurityOption -and $TargetVMSecurityOption -eq $TargetVMSecurityTypes.TrustedLaunch -and + $HasEnableSecureBoot -and -not $secureBootEnabled) { + throw "-EnableSecureBoot 'false' cannot be used with -TargetVMSecurityOption 'TrustedLaunch'. Trusted Launch requires Secure Boot." + } + CheckResourceGraphModuleDependency CheckResourcesModuleDependency @@ -208,6 +236,8 @@ function New-AzMigrateLocalServerReplication { $null = $PSBoundParameters.Remove('TargetTestVirtualSwitchId') $null = $PSBoundParameters.Remove('IsDynamicMemoryEnabled') $null = $PSBoundParameters.Remove('MigrateAsArcVM') + $null = $PSBoundParameters.Remove('TargetVMSecurityOption') + $null = $PSBoundParameters.Remove('EnableSecureBoot') $null = $PSBoundParameters.Remove('TargetVMRam') $null = $PSBoundParameters.Remove('DiskToInclude') $null = $PSBoundParameters.Remove('NicToInclude') @@ -732,6 +762,39 @@ function New-AzMigrateLocalServerReplication { $customProperties.HyperVGeneration = if ($machine.Firmware -ieq "BIOS") { "1" } else { "2" } } + # Gen 1 target VMs do not support Secure Boot or vTPM; fail before the service round-trip. + if ($HasTargetVMSecurityOption -or $HasEnableSecureBoot) { + $securityType = if ($HasTargetVMSecurityOption) { $TargetVMSecurityOption } else { $TargetVMSecurityTypes.Standard } + + # For VMware sources -MachineName is an opaque id, so report the discovered name. + $sourceName = if ([string]::IsNullOrEmpty($machine.DisplayName)) { $MachineName } else { $machine.DisplayName } + + if ($securityType -eq $TargetVMSecurityTypes.TrustedLaunch) { + $secureBootEnabled = $true + } + + if ($customProperties.HyperVGeneration -eq "1" -and + ($securityType -eq $TargetVMSecurityTypes.TrustedLaunch -or $secureBootEnabled)) { + throw "Secure Boot and Trusted Launch require a Generation 2 target VM. The source server '$sourceName' maps to a Generation 1 target VM." + } + + # Only send securityOption once a choice is expressed. '-TargetVMSecurityOption Standard' + # on its own is not a choice about Secure Boot, so the target keeps inheriting the source. + if ($securityType -eq $TargetVMSecurityTypes.TrustedLaunch) { + $customProperties.SecurityOption = $SecurityOptions.TrustedLaunch + } + elseif ($HasEnableSecureBoot) { + if (-not $secureBootEnabled -and $customProperties.HyperVGeneration -eq "2") { + # The service rejects turning Secure Boot off for a Gen 2 source that has it on. + if ($true -eq (Get-AzMigrateSourceSecureBootState -MachineId $MachineId)) { + throw "Source server '$sourceName' has Secure Boot enabled, so it cannot be migrated with -EnableSecureBoot 'false'. Omit -EnableSecureBoot to keep Secure Boot enabled on the target VM, or disable Secure Boot on the source server first." + } + } + + $customProperties.SecurityOption = if ($secureBootEnabled) { $SecurityOptions.SecureBootEnabled } else { $SecurityOptions.None } + } + } + # Validate TargetVMCPUCore if ($HasTargetVMCPUCore) { diff --git a/src/Migrate/Migrate.Autorest/custom/Set-AzMigrateLocalServerReplication.ps1 b/src/Migrate/Migrate.Autorest/custom/Set-AzMigrateLocalServerReplication.ps1 index baf5a0d5eb0d..d88d33394076 100644 --- a/src/Migrate/Migrate.Autorest/custom/Set-AzMigrateLocalServerReplication.ps1 +++ b/src/Migrate/Migrate.Autorest/custom/Set-AzMigrateLocalServerReplication.ps1 @@ -72,6 +72,22 @@ function Set-AzMigrateLocalServerReplication { # Specifies the OS type of the VM, either WindowsGuest or LinuxGuest. ${OsType}, + [Parameter()] + [ValidateSet("Standard", "TrustedLaunch")] + [ArgumentCompleter( { "Standard", "TrustedLaunch" })] + [Microsoft.Azure.PowerShell.Cmdlets.Migrate.Category('Path')] + [System.String] + # Specifies the security type of the target VM. 'TrustedLaunch' enables Secure Boot and vTPM, and implies -EnableSecureBoot 'true'. Only supported for Generation 2 target VMs. + ${TargetVMSecurityOption}, + + [Parameter()] + [ValidateSet("true" , "false")] + [ArgumentCompleter( { "true" , "false" })] + [Microsoft.Azure.PowerShell.Cmdlets.Migrate.Category('Path')] + [System.String] + # Specifies whether Secure Boot is enabled on the target VM. Only supported for Generation 2 target VMs. + ${EnableSecureBoot}, + [Parameter()] [Microsoft.Azure.PowerShell.Cmdlets.Migrate.Category('Path')] [Microsoft.Azure.PowerShell.Cmdlets.Migrate.Runtime.DefaultInfo(Script = '(Get-AzContext).Subscription.Id')] @@ -133,6 +149,18 @@ function Set-AzMigrateLocalServerReplication { $helperPath = [System.IO.Path]::Combine($PSScriptRoot, "Helper", "AzLocalCommonHelper.ps1") Import-Module $helperPath + $HasTargetVMSecurityOption = $PSBoundParameters.ContainsKey('TargetVMSecurityOption') + $HasEnableSecureBoot = $PSBoundParameters.ContainsKey('EnableSecureBoot') + if ($HasEnableSecureBoot) { + $secureBootEnabled = [System.Convert]::ToBoolean($EnableSecureBoot) + } + + # Purely a contradiction between parameters, so reject it before the module and service checks. + if ($HasTargetVMSecurityOption -and $TargetVMSecurityOption -eq $TargetVMSecurityTypes.TrustedLaunch -and + $HasEnableSecureBoot -and -not $secureBootEnabled) { + throw "-EnableSecureBoot 'false' cannot be used with -TargetVMSecurityOption 'TrustedLaunch'. Trusted Launch requires Secure Boot." + } + CheckResourcesModuleDependency $HasTargetObjectId = $PSBoundParameters.ContainsKey('TargetObjectID') @@ -153,6 +181,8 @@ function Set-AzMigrateLocalServerReplication { $null = $PSBoundParameters.Remove('NicToInclude') $null = $PSBoundParameters.Remove('TargetObjectID') $null = $PSBoundParameters.Remove('OsType') + $null = $PSBoundParameters.Remove('TargetVMSecurityOption') + $null = $PSBoundParameters.Remove('EnableSecureBoot') $null = $PSBoundParameters.Remove('WhatIf') $null = $PSBoundParameters.Remove('Confirm') @@ -209,6 +239,40 @@ function Set-AzMigrateLocalServerReplication { $customPropertiesUpdate.InstanceType = $AzLocalInstanceTypes.VMwareToAzLocal } + # Gen 1 target VMs do not support Secure Boot or vTPM; fail before the service round-trip. + if ($HasTargetVMSecurityOption -or $HasEnableSecureBoot) { + $securityType = if ($HasTargetVMSecurityOption) { $TargetVMSecurityOption } else { $TargetVMSecurityTypes.Standard } + + # Trusted Launch always includes Secure Boot; moving to Standard drops vTPM but keeps it, matching the portal. + if ($securityType -eq $TargetVMSecurityTypes.TrustedLaunch -or -not $HasEnableSecureBoot) { + $secureBootEnabled = $true + } + + if ($customProperties.HyperVGeneration -eq "1") { + # Only an explicit request is an error. The inherit-Secure-Boot default above is a + # Gen 2 convention, so on Gen 1 it resolves to None instead of being rejected. + if ($securityType -eq $TargetVMSecurityTypes.TrustedLaunch -or ($HasEnableSecureBoot -and $secureBootEnabled)) { + throw "Secure Boot and Trusted Launch require a Generation 2 target VM. Protected item '$TargetObjectID' has a Generation 1 target VM." + } + + $secureBootEnabled = $false + } + + if (-not $secureBootEnabled -and $customProperties.HyperVGeneration -eq "2") { + # The service rejects turning Secure Boot off for a Gen 2 source that has it on. + if ($true -eq (Get-AzMigrateSourceSecureBootState -MachineId $customProperties.FabricDiscoveryMachineId)) { + # For VMware sources $MachineName is an opaque id, so report the discovered name. + $sourceName = if ([string]::IsNullOrEmpty($customProperties.SourceVMName)) { $MachineName } else { $customProperties.SourceVMName } + throw "Source server '$sourceName' has Secure Boot enabled, so it cannot be migrated with -EnableSecureBoot 'false'. Omit -EnableSecureBoot to keep Secure Boot enabled on the target VM, or disable Secure Boot on the source server first." + } + } + + $customPropertiesUpdate.SecurityOption = + if ($securityType -eq $TargetVMSecurityTypes.TrustedLaunch) { $SecurityOptions.TrustedLaunch } + elseif ($secureBootEnabled) { $SecurityOptions.SecureBootEnabled } + else { $SecurityOptions.None } + } + # Update target CPU core if ($HasTargetVMCPUCore) { if ($TargetVMCPUCore -lt $TargetVMCPUCores.Min -or $TargetVMCPUCore -gt $TargetVMCPUCores.Max) diff --git a/src/Migrate/Migrate.Autorest/docs/New-AzMigrateLocalServerReplication.md b/src/Migrate/Migrate.Autorest/docs/New-AzMigrateLocalServerReplication.md index 4510a2a89806..6f14695f95f9 100644 --- a/src/Migrate/Migrate.Autorest/docs/New-AzMigrateLocalServerReplication.md +++ b/src/Migrate/Migrate.Autorest/docs/New-AzMigrateLocalServerReplication.md @@ -16,10 +16,10 @@ Starts replication for the specified server. ``` New-AzMigrateLocalServerReplication -MachineId -OSDiskID -SourceApplianceName -TargetApplianceName -TargetResourceGroupId -TargetStoragePathId - -TargetVirtualSwitchId -TargetVMName [-IsDynamicMemoryEnabled ] - [-MigrateAsArcVM ] [-SubscriptionId ] [-TargetTestVirtualSwitchId ] - [-TargetVMCPUCore ] [-TargetVMRam ] [-DefaultProfile ] [-Confirm] [-WhatIf] - [] + -TargetVirtualSwitchId -TargetVMName [-EnableSecureBoot ] + [-IsDynamicMemoryEnabled ] [-MigrateAsArcVM ] [-SubscriptionId ] + [-TargetTestVirtualSwitchId ] [-TargetVMCPUCore ] [-TargetVMRam ] + [-TargetVMSecurityOption ] [-DefaultProfile ] [-Confirm] [-WhatIf] [] ``` ### ByIdPowerUser @@ -27,9 +27,9 @@ New-AzMigrateLocalServerReplication -MachineId -OSDiskID -Sour New-AzMigrateLocalServerReplication -DiskToInclude -MachineId -NicToInclude -SourceApplianceName -TargetApplianceName -TargetResourceGroupId -TargetStoragePathId -TargetVMName - [-IsDynamicMemoryEnabled ] [-MigrateAsArcVM ] [-SubscriptionId ] - [-TargetVMCPUCore ] [-TargetVMRam ] [-DefaultProfile ] [-Confirm] [-WhatIf] - [] + [-EnableSecureBoot ] [-IsDynamicMemoryEnabled ] [-MigrateAsArcVM ] + [-SubscriptionId ] [-TargetVMCPUCore ] [-TargetVMRam ] + [-TargetVMSecurityOption ] [-DefaultProfile ] [-Confirm] [-WhatIf] [] ``` ## DESCRIPTION @@ -151,6 +151,23 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -EnableSecureBoot +Specifies whether Secure Boot is enabled on the target VM. +Only supported for Generation 2 target VMs. +When omitted, the target VM inherits the Secure Boot setting of the source server. + +```yaml +Type: System.String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -IsDynamicMemoryEnabled Specifies if RAM is dynamic or not. @@ -377,6 +394,23 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -TargetVMSecurityOption +Specifies the security type of the target VM. +'TrustedLaunch' enables Secure Boot and vTPM, and implies -EnableSecureBoot 'true'. +Only supported for Generation 2 target VMs. + +```yaml +Type: System.String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/src/Migrate/Migrate.Autorest/docs/Set-AzMigrateLocalServerReplication.md b/src/Migrate/Migrate.Autorest/docs/Set-AzMigrateLocalServerReplication.md index cd0bbf1f70ed..954dc33e66ff 100644 --- a/src/Migrate/Migrate.Autorest/docs/Set-AzMigrateLocalServerReplication.md +++ b/src/Migrate/Migrate.Autorest/docs/Set-AzMigrateLocalServerReplication.md @@ -14,9 +14,10 @@ Updates the target properties for the replicating server. ``` Set-AzMigrateLocalServerReplication -TargetObjectID - [-DynamicMemoryConfig ] [-IsDynamicMemoryEnabled ] - [-NicToInclude ] [-OsType ] [-SubscriptionId ] [-TargetVMCPUCore ] - [-TargetVMRam ] [-DefaultProfile ] [-Confirm] [-WhatIf] [] + [-DynamicMemoryConfig ] [-EnableSecureBoot ] + [-IsDynamicMemoryEnabled ] [-NicToInclude ] [-OsType ] + [-SubscriptionId ] [-TargetVMCPUCore ] [-TargetVMRam ] + [-TargetVMSecurityOption ] [-DefaultProfile ] [-Confirm] [-WhatIf] [] ``` ## DESCRIPTION @@ -98,6 +99,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -EnableSecureBoot +Specifies whether Secure Boot is enabled on the target VM. +Only supported for Generation 2 target VMs. + +```yaml +Type: System.String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -IsDynamicMemoryEnabled Specifies if RAM is dynamic or not. @@ -204,6 +221,23 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -TargetVMSecurityOption +Specifies the security type of the target VM. +'TrustedLaunch' enables Secure Boot and vTPM, and implies -EnableSecureBoot 'true'. +Only supported for Generation 2 target VMs. + +```yaml +Type: System.String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/src/Migrate/Migrate.Autorest/generate-info.json b/src/Migrate/Migrate.Autorest/generate-info.json index c468da572e83..b20325c0608e 100644 --- a/src/Migrate/Migrate.Autorest/generate-info.json +++ b/src/Migrate/Migrate.Autorest/generate-info.json @@ -1,3 +1,3 @@ { - "generate_Id": "f33887be-d28b-421c-b9c4-1e18f174c636" + "generate_Id": "ab6416ec-bbc9-429a-a580-86fbd91a3500" } diff --git a/src/Migrate/Migrate.Autorest/test/New-AzMigrateLocalServerReplication.Tests.ps1 b/src/Migrate/Migrate.Autorest/test/New-AzMigrateLocalServerReplication.Tests.ps1 index f679e7cc14b3..433e1c37eedd 100644 --- a/src/Migrate/Migrate.Autorest/test/New-AzMigrateLocalServerReplication.Tests.ps1 +++ b/src/Migrate/Migrate.Autorest/test/New-AzMigrateLocalServerReplication.Tests.ps1 @@ -35,4 +35,165 @@ Describe 'New-AzMigrateLocalServerReplication' { $cmd = Get-Command Set-AzMigrateLocalServerReplication $cmd.Parameters.Keys | Should -Not -Contain 'MigrateAsArcVM' } + + It 'TargetVMSecurityOption-ParameterExists' { + foreach ($name in 'New-AzMigrateLocalServerReplication', 'Set-AzMigrateLocalServerReplication') { + foreach ($paramName in 'TargetVMSecurityOption', 'EnableSecureBoot') { + $param = (Get-Command $name).Parameters[$paramName] + $param | Should -Not -BeNullOrEmpty + $param.ParameterType.Name | Should -Be 'String' + } + } + } + + It 'TargetVMSecurityOption-OffersOnlySupportedValues' { + # 'EnablevTPM' and 'SecureBootEnabled' are wire values, not user-facing security types. + foreach ($name in 'New-AzMigrateLocalServerReplication', 'Set-AzMigrateLocalServerReplication') { + $completer = (Get-Command $name).Parameters['TargetVMSecurityOption'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ArgumentCompleterAttribute] } + $values = & $completer.ScriptBlock + $values | Should -Be @('Standard', 'TrustedLaunch') + $values | Should -Not -Contain 'EnablevTPM' + } + } + + It 'TargetVMSecurityOption-RejectsUnsupportedValue' { + # Rejected while binding the inner cmdlet, so no service call is made. + $err = $null + try { + New-AzMigrateLocalServerReplication ` + -MachineId 'machine' ` + -TargetStoragePathId 'storagePath' ` + -TargetResourceGroupId 'resourceGroup' ` + -TargetVMName 'vm' ` + -SourceApplianceName 'source' ` + -TargetApplianceName 'target' ` + -TargetVirtualSwitchId 'switch' ` + -OSDiskID 'osDisk' ` + -TargetVMSecurityOption 'EnablevTPM' ` + -ErrorAction Stop + } + catch { + $err = $_ + } + + $err | Should -Not -BeNullOrEmpty + $err.Exception.Message | Should -BeLike '*does not belong to the set*' + } + + It 'EnableSecureBoot-RejectsTrustedLaunchOptOut' { + $err = $null + try { + New-AzMigrateLocalServerReplication ` + -MachineId 'machine' ` + -TargetStoragePathId 'storagePath' ` + -TargetResourceGroupId 'resourceGroup' ` + -TargetVMName 'vm' ` + -SourceApplianceName 'source' ` + -TargetApplianceName 'target' ` + -TargetVirtualSwitchId 'switch' ` + -OSDiskID 'osDisk' ` + -TargetVMSecurityOption 'TrustedLaunch' ` + -EnableSecureBoot 'false' ` + -ErrorAction Stop + } + catch { + $err = $_ + } + + $err | Should -Not -BeNullOrEmpty + $err.Exception.Message | Should -BeLike '*Trusted Launch requires Secure Boot*' + } + + # The custom helpers live in a nested module that Get-Module and InModuleScope cannot reach, so + # go through the root module and shadow the REST call inside that scope. + function Invoke-SecureBootLookup { + param([int]$StatusCode = 200, [string]$Content = '{}', [switch]$FailTransport) + + $custom = (Get-Module Az.Migrate).NestedModules | + Where-Object { $_.Name -eq 'Az.Migrate.custom' } + + & $custom { + param($statusCode, $content, $failTransport) + + function Invoke-AzRestMethod { + param($Path, $Method) + $script:capturedPath = $Path + if ($failTransport) { throw 'transport failure' } + [PSCustomObject]@{ StatusCode = $statusCode; Content = $content } + } + + try { + $state = Get-AzMigrateSourceSecureBootState -MachineId '/machines/m' + [PSCustomObject]@{ + ApiVersion = $ApiVersions.OffAzureMachineRead + Path = $script:capturedPath + State = $state + IsBool = $state -is [bool] + } + } + finally { + Remove-Item Function:\Invoke-AzRestMethod -ErrorAction SilentlyContinue + Remove-Variable -Name capturedPath -Scope Script -ErrorAction SilentlyContinue + } + } $StatusCode $Content $FailTransport.IsPresent + } + + It 'SecureBootLookup-ReadsStateFromNewerApiVersion' { + $on = Invoke-SecureBootLookup -Content '{"properties":{"secureBootEnabled":true}}' + $on.ApiVersion | Should -Be '2024-12-01-preview' + $on.Path | Should -Be '/machines/m?api-version=2024-12-01-preview' + ($on.IsBool -and $on.State) | Should -BeTrue + + $off = Invoke-SecureBootLookup -Content '{"properties":{"secureBootEnabled":false}}' + ($off.IsBool -and -not $off.State) | Should -BeTrue + } + + It 'SecureBootLookup-FailsOpenWhenStateUnknown' { + # Older appliances, and clouds still serving the GA contract, omit the field entirely. + $absent = Invoke-SecureBootLookup -Content '{"properties":{"displayName":"vm"}}' + $null -eq $absent.State | Should -BeTrue + + $notFound = Invoke-SecureBootLookup -StatusCode 404 -Content '{}' + $null -eq $notFound.State | Should -BeTrue + + $broken = Invoke-SecureBootLookup -FailTransport + $null -eq $broken.State | Should -BeTrue + } + + # LiveOnly: the cmdlet rejects an already-replicating VM via a pre-existence lookup that returns + # 404 on a first run, and the recorder does not persist that exchange, so playback cannot satisfy + # it. The same limitation is why ByIdDefaultUser and ByIdPowerUser above are skipped. +} + +Describe 'New-AzMigrateLocalServerReplicationSecurityOption' -Tag 'LiveOnly' { + It 'ByIdSecurityOptionTrustedLaunch' { + # The service accepts securityOption on create but returns null for it on later GETs, so the + # only way to catch a dropped or wrong assignment is to inspect the outgoing request. + $script:capturedBody = $null + $capture = { + param($message, $eventListener, $next) + if ($message.Method.Method -eq 'PUT' -and $message.RequestUri.AbsoluteUri -match '/protectedItems/') { + $script:capturedBody = $message.Content.ReadAsStringAsync().Result + } + $next.SendAsync($message, $eventListener) + } + + $job = New-AzMigrateLocalServerReplication ` + -MachineId $env.hciTvmMachineId ` + -TargetStoragePathId $env.hciTvmStoragePathId ` + -TargetResourceGroupId $env.hciTvmTargetRgId ` + -TargetVMName $env.hciTvmTargetVMName ` + -SourceApplianceName $env.hciTvmSourceApplianceName ` + -TargetApplianceName $env.hciTvmTargetApplianceName ` + -TargetVirtualSwitchId $env.hciTvmVirtualSwitchId ` + -OSDiskID $env.hciTvmOSDiskId ` + -TargetVMSecurityOption 'TrustedLaunch' ` + -HttpPipelinePrepend $capture + + $job | Should -Not -BeNullOrEmpty + $script:capturedBody | Should -Not -BeNullOrEmpty + $script:capturedBody | Should -Match '"securityOption"\s*:\s*"TrustedLaunch"' + $script:capturedBody | Should -Match '"hyperVGeneration"\s*:\s*"2"' + } } diff --git a/src/Migrate/Migrate.Autorest/test/env.json b/src/Migrate/Migrate.Autorest/test/env.json index e36d854e0e66..20adbdbdc9a9 100644 --- a/src/Migrate/Migrate.Autorest/test/env.json +++ b/src/Migrate/Migrate.Autorest/test/env.json @@ -98,5 +98,15 @@ "hciJobId": "/subscriptions/0daa57b3-f823-4921-a09a-33c048e64022/resourceGroups/aszmige2etestCIRG01/providers/Microsoft.DataReplication/replicationVaults/aszmigtest1c100b4replicationvault/jobs/5348d490-92d0-424b-9fe1-6d126cbcee0e", "srsMachineId5": "/Subscriptions/6b72781d-4550-419b-a56e-44055341a88e/resourceGroups/cbtgqlsrcrg/providers/Microsoft.RecoveryServices/vaults/ecygqlapp4055vault/replicationFabrics/ecygqlapp2fd6replicationfabric/replicationProtectionContainers/ecygqlapp2fd6replicationcontainer/replicationMigrationItems/idclab-vcen8-fareast-corp-micro-d2408603-48eb-434f-8cd5-f34828328495_5037d276-0d72-7b96-7f6d-2ce50a4c05e5", "srsSubscriptionId5": "6b72781d-4550-419b-a56e-44055341a88e", - "sqlServerLicenseType": "PAYG" + "sqlServerLicenseType": "PAYG", + "hciTvmSubscriptionId": "265ca7e5-909a-455d-9459-7c7041c1c37d", + "hciTvmMachineId": "/subscriptions/265ca7e5-909a-455d-9459-7c7041c1c37d/resourceGroups/bpuram-tvme2ehv-ecy-rg/providers/Microsoft.OffAzure/HyperVSites/src5185site/machines/dac5820e-571c-435e-8940-5c3299a19f2e", + "hciTvmOSDiskId": "Microsoft:DAC5820E-571C-435E-8940-5C3299A19F2E\\E8731E55-F448-4FB8-B5FD-D3B9B244DCE2\\0\\0\\L", + "hciTvmNicId": "Microsoft:DAC5820E-571C-435E-8940-5C3299A19F2E\\C6CEC83F-A2D9-4E06-855A-AB3451F951B3", + "hciTvmStoragePathId": "/subscriptions/d41eb627-825d-4419-a14d-c6ad485f4110/resourcegroups/edgeci-registration-s46r1405-zqcgn3th/providers/microsoft.azurestackhci/storagecontainers/userstorage4-f65c130de71d434eaeab111d3ea02f2a", + "hciTvmTargetRgId": "/subscriptions/d41eb627-825d-4419-a14d-c6ad485f4110/resourceGroups/2503Uninstall", + "hciTvmVirtualSwitchId": "/subscriptions/d41eb627-825d-4419-a14d-c6ad485f4110/resourceGroups/EDGECI-REGISTRATION-s46r1405-ZqcGn3TH/providers/microsoft.azurestackhci/logicalnetworks/s46r1405-lnet", + "hciTvmSourceApplianceName": "src", + "hciTvmTargetApplianceName": "tgt", + "hciTvmTargetVMName": "tvm-secopt-ws19" } diff --git a/src/Migrate/Migrate/ChangeLog.md b/src/Migrate/Migrate/ChangeLog.md index 8ef97e9cf4c6..ca9f52e3e388 100644 --- a/src/Migrate/Migrate/ChangeLog.md +++ b/src/Migrate/Migrate/ChangeLog.md @@ -20,6 +20,9 @@ ## Upcoming Release * Updated Azure Data Replication API version from 2024-09-01 to 2026-05-01 * Added 'MigrateAsArcVM' parameter to 'New-AzMigrateLocalServerReplication' to support migrating VMs as Azure Arc-enabled VMs +* Added 'TargetVMSecurityOption' and 'EnableSecureBoot' parameters to 'New-AzMigrateLocalServerReplication' and 'Set-AzMigrateLocalServerReplication' to configure Secure Boot and Trusted Launch on the target virtual machine (VM). 'TargetVMSecurityOption' accepts 'Standard' or 'TrustedLaunch', where 'TrustedLaunch' enables both Secure Boot and virtual Trusted Platform Module (vTPM). Only Generation 2 target VMs are supported +* Added a client-side check that rejects '-EnableSecureBoot false' when the source server has Secure Boot enabled, replacing an opaque service-side failure with an actionable error. The check is skipped when the source Secure Boot state cannot be determined +* Updated 'Initialize-AzMigrateLocalReplicationInfrastructure' to grant the 'Storage Account Contributor' role instead of 'Contributor' on the cache storage account to the replication vault managed identity and to the source and target appliance applications ## Version 3.0.0 * [Upgraded code generator](https://go.microsoft.com/fwlink/?linkid=2340249)