-
Notifications
You must be signed in to change notification settings - Fork 4.2k
[RecoveryServices.Backup] Add Cross Subscription Backup (CSB) support for Azure VM #29799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,6 +81,7 @@ public RestAzureNS.AzureOperationResponse<ProtectedItemResource> EnableProtectio | |
| string[] exclusionDisksList = ProviderData.ContainsKey(ItemParams.ExclusionDisksList) ? (string[])ProviderData[ItemParams.ExclusionDisksList] : null; | ||
| SwitchParameter resetDiskExclusionSetting = ProviderData.ContainsKey(ItemParams.ResetExclusionSettings) ? (SwitchParameter)ProviderData[ItemParams.ResetExclusionSettings] : new SwitchParameter(false); | ||
| bool excludeAllDataDisks = ProviderData.ContainsKey(ItemParams.ExcludeAllDataDisks) ? (bool)ProviderData[ItemParams.ExcludeAllDataDisks] : false; | ||
| string containerSubscriptionId = ProviderData.ContainsKey(ItemParams.ContainerSubscriptionId) ? (string)ProviderData[ItemParams.ContainerSubscriptionId] : null; | ||
| PolicyBase policy = (PolicyBase)ProviderData[ItemParams.Policy]; | ||
| ItemBase itemBase = ProviderData.ContainsKey(ItemParams.Item) ? (ItemBase)ProviderData[ItemParams.Item] : null; | ||
| AzureVmItem item = ProviderData.ContainsKey(ItemParams.Item) ? (AzureVmItem)ProviderData[ItemParams.Item] : null; | ||
|
|
@@ -141,27 +142,48 @@ public RestAzureNS.AzureOperationResponse<ProtectedItemResource> EnableProtectio | |
| azureVMResourceGroupName, | ||
| policy); | ||
|
|
||
| WorkloadProtectableItemResource protectableObjectResource = | ||
| GetAzureVMProtectableObject( | ||
| azureVMName, | ||
| azureVMRGName, | ||
| isComputeAzureVM, | ||
| vaultName: vaultName, | ||
| resourceGroupName: resourceGroupName); | ||
|
|
||
| Dictionary<UriEnums, string> keyValueDict = | ||
| HelperUtils.ParseUri(protectableObjectResource.Id); | ||
| containerUri = HelperUtils.GetContainerUri( | ||
| keyValueDict, protectableObjectResource.Id); | ||
| protectedItemUri = HelperUtils.GetProtectableItemUri( | ||
| keyValueDict, protectableObjectResource.Id); | ||
|
|
||
| IaaSVMProtectableItem iaasVmProtectableItem = | ||
| (IaaSVMProtectableItem)protectableObjectResource.Properties; | ||
|
|
||
| if (iaasVmProtectableItem != null) | ||
| if (!string.IsNullOrEmpty(containerSubscriptionId)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: on the CSB path we skip discovery entirely, so a typo in -Name/-ResourceGroupName or an ineligible VM no longer surfaces the clear "VM not discovered" client error and instead fails opaquely at the backend — is a lightweight existence/eligibility check (or at least a doc note) worth adding? |
||
| { | ||
| sourceResourceId = iaasVmProtectableItem.VirtualMachineId; | ||
| // Cross Subscription Backup (CSB): the VM resides in a different subscription than the vault. | ||
| // Discovery (RefreshContainers/ListProtectableItems) only operates on the vault's subscription | ||
| // and will not find the cross-subscription VM. Hence, we skip discovery and construct the | ||
| // container URI, protected item URI and source resource ID directly. The backend derives the | ||
| // VM's subscription from the source resource ID and configures backup accordingly. | ||
| string vmVersion = isComputeAzureVM ? computeAzureVMVersion : classicComputeAzureVMVersion; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. -ContainerSubscriptionId is only in AzureVMComputeParameterSet, so isComputeAzureVM is always true here — the classic (iaasvmcontainer / ClassicCompute) branch of these ternaries can never execute; consider dropping it or intentionally supporting classic. |
||
| string containerType = isComputeAzureVM ? "iaasvmcontainerv2" : "iaasvmcontainer"; | ||
|
|
||
| containerUri = string.Format("IaasVMContainer;{0};{1};{2}", | ||
| containerType, azureVMRGName, azureVMName); | ||
| protectedItemUri = string.Format("VM;{0};{1};{2}", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: the rest of the module emits lowercase "vm;" (via GetProtectableItemUri); "VM;" works (ARM is case-insensitive) but is inconsistent. |
||
| containerType, azureVMRGName, azureVMName); | ||
| sourceResourceId = string.Format( | ||
| "/subscriptions/{0}/resourceGroups/{1}/providers/{2}/virtualMachines/{3}", | ||
| containerSubscriptionId, azureVMRGName, vmVersion, azureVMName); | ||
| } | ||
| else | ||
| { | ||
| WorkloadProtectableItemResource protectableObjectResource = | ||
| GetAzureVMProtectableObject( | ||
| azureVMName, | ||
| azureVMRGName, | ||
| isComputeAzureVM, | ||
| vaultName: vaultName, | ||
| resourceGroupName: resourceGroupName); | ||
|
|
||
| Dictionary<UriEnums, string> keyValueDict = | ||
| HelperUtils.ParseUri(protectableObjectResource.Id); | ||
| containerUri = HelperUtils.GetContainerUri( | ||
| keyValueDict, protectableObjectResource.Id); | ||
| protectedItemUri = HelperUtils.GetProtectableItemUri( | ||
| keyValueDict, protectableObjectResource.Id); | ||
|
|
||
| IaaSVMProtectableItem iaasVmProtectableItem = | ||
| (IaaSVMProtectableItem)protectableObjectResource.Properties; | ||
|
|
||
| if (iaasVmProtectableItem != null) | ||
| { | ||
| sourceResourceId = iaasVmProtectableItem.VirtualMachineId; | ||
| } | ||
| } | ||
| } | ||
| else if(isDiskExclusionParamPresent && parameterSetName.Contains("Modify")) | ||
|
|
@@ -521,7 +543,23 @@ public RestAzureNS.AzureOperationResponse TriggerRestore() | |
| string cVMOsDiskEncryptionSetId = ProviderData.ContainsKey(RestoreVMBackupItemParams.CVMOsDiskEncryptionSetId) ? | ||
| ProviderData[RestoreVMBackupItemParams.CVMOsDiskEncryptionSetId].ToString() : null; | ||
|
|
||
| if (targetSubscriptionId == null || targetSubscriptionId == "") targetSubscriptionId = ServiceClientAdapter.SubscriptionId; | ||
| if (targetSubscriptionId == null || targetSubscriptionId == "") | ||
| { | ||
| // For Original Location Recovery (OLR) of a cross-subscription (CSB) protected item, the disks | ||
| // are restored to the VM's original subscription, which may differ from the vault's subscription. | ||
| // Extract the container (VM) subscription from the recovery point's SourceResourceId so that the | ||
| // target storage account is resolved in the correct subscription. No new input is required from | ||
| // the customer. For all other cases, fall back to the vault's (context) subscription. | ||
| if (string.Compare(restoreType, "OriginalLocation") == 0 && !string.IsNullOrEmpty(rp.SourceResourceId)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: deriving targetSubscriptionId from rp.SourceResourceId for OLR (so staging storage resolves in the VM's subscription) is the right fix and matches the existing restoreType convention. |
||
| { | ||
| Dictionary<UriEnums, string> sourceResourceUriDict = HelperUtils.ParseUri(rp.SourceResourceId); | ||
| targetSubscriptionId = HelperUtils.GetSubscriptionIdFromId(sourceResourceUriDict, rp.SourceResourceId); | ||
| } | ||
| else | ||
| { | ||
| targetSubscriptionId = ServiceClientAdapter.SubscriptionId; | ||
| } | ||
| } | ||
|
|
||
| GenericResource storageAccountResource = ServiceClientAdapter.GetStorageAccountResource(storageAccountName, targetSubscriptionId); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1409,3 +1409,151 @@ function Test-AzureRestoreWithCVMOsDiskEncryptionSetId() | |
| Delete-VM $targetRG $targetVMName | ||
| } | ||
| } | ||
|
|
||
| function Test-AzureVMCSBProtection | ||
| { | ||
| # Cross Subscription Backup (CSB): the vault and the VM to be protected live in | ||
| # different subscriptions. These values point to the recording environment. | ||
| $resourceGroupName = "singhprab-csb-vault-rg-ea" | ||
| $vaultName = "singhprab-csb-vault-ea" | ||
| # The VM (container) resides in a subscription different from the vault's subscription. | ||
| $vmName = "singhprab-ps-vm3" | ||
| $vmResourceGroupName = "singhprab-rg-1c" | ||
| $containerSubscriptionId = "80abcfe3-b410-42b2-983f-df23cba781dc" | ||
| # Policy used to initially protect the VM, and the policy to switch to via modify protection. | ||
| $policyName = "singhprab" | ||
| $modifyPolicyName = "singhprab2" | ||
|
|
||
| try | ||
| { | ||
| # Setup | ||
| $vault = Get-AzRecoveryServicesVault -ResourceGroupName $resourceGroupName -Name $vaultName | ||
|
|
||
| $policy = Get-AzRecoveryServicesBackupProtectionPolicy ` | ||
| -VaultId $vault.ID ` | ||
| -Name $policyName; | ||
|
|
||
| # Sleep to give the service time to add the policy to the vault | ||
| Start-TestSleep -Seconds 5 | ||
|
|
||
| # Enable protection for a cross-subscription VM using the new -ContainerSubscriptionId parameter. | ||
| # This path skips discovery/RefreshContainers and constructs the request directly. | ||
| Enable-AzRecoveryServicesBackupProtection ` | ||
| -VaultId $vault.ID ` | ||
| -Policy $policy ` | ||
| -Name $vmName ` | ||
| -ResourceGroupName $vmResourceGroupName ` | ||
| -ContainerSubscriptionId $containerSubscriptionId; | ||
|
|
||
| $item = Get-AzRecoveryServicesBackupItem ` | ||
| -BackupManagementType AzureVM -WorkloadType AzureVM -VaultId $vault.ID ` | ||
| | Where-Object { $_.Name -match $vmName } | ||
|
|
||
| Assert-NotNull $item; | ||
|
|
||
| # The source resource id (and hence the item) must belong to the VM's subscription, not the vault's. | ||
| Assert-True { $item.SourceResourceId -match $vmName }; | ||
| Assert-True { $item.SourceResourceId -match $vmResourceGroupName }; | ||
| Assert-True { $item.ContainerSubscriptionId -eq $containerSubscriptionId }; | ||
|
|
||
| # The item must be protected with the initial policy. | ||
| Assert-True { $item.ProtectionPolicyName -eq $policyName }; | ||
|
|
||
| # Modify protection: switch the already-protected CSB item to a different policy. | ||
| # This uses the ModifyProtection parameter set (-Item), which derives the container | ||
| # from the item's ARM id and works for cross-subscription items without extra input. | ||
| $modifyPolicy = Get-AzRecoveryServicesBackupProtectionPolicy ` | ||
| -VaultId $vault.ID ` | ||
| -Name $modifyPolicyName; | ||
|
|
||
| Enable-AzRecoveryServicesBackupProtection ` | ||
| -VaultId $vault.ID ` | ||
| -Policy $modifyPolicy ` | ||
| -Item $item; | ||
|
|
||
| $modifiedItem = Get-AzRecoveryServicesBackupItem ` | ||
| -BackupManagementType AzureVM -WorkloadType AzureVM -VaultId $vault.ID ` | ||
| | Where-Object { $_.Name -match $vmName } | ||
|
|
||
| Assert-NotNull $modifiedItem; | ||
|
|
||
| # The item must now be associated with the new policy, and remain a CSB item. | ||
| Assert-True { $modifiedItem.ProtectionPolicyName -eq $modifyPolicyName }; | ||
| Assert-True { $modifiedItem.ContainerSubscriptionId -eq $containerSubscriptionId }; | ||
| } | ||
| finally | ||
| { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This finally is empty but the test enables protection — file convention (e.g. Test-AzureRestoreWithCVMOsDiskEncryptionSetId) disables/deletes in finally; please clean up the protected item. |
||
| # Cleanup | ||
| } | ||
| } | ||
|
|
||
| function Test-AzureVMCSBRestoreOLR | ||
| { | ||
| # Original Location Recovery (OLR) for a Cross Subscription Backup protected item. | ||
| # No target subscription/resource group input is required - the VM's subscription is derived | ||
| # from the recovery point's SourceResourceId, and the staging storage account is resolved in | ||
| # that subscription. This fetches an existing recovery point (no fresh backup is triggered). | ||
| # These values point to the recording environment. | ||
| $resourceGroupName = "singhprab-csb-vault-rg-ea" | ||
| $vaultName = "singhprab-csb-vault-ea" | ||
| $vmName = "singhprab-csb-vm-ea" | ||
| # Staging storage account (resides in the VM's subscription). | ||
| $saName = "asmaskasaea" | ||
| $saResourceGroupName = "asmaskarrg" | ||
|
|
||
| try | ||
| { | ||
| # Setup | ||
| $vault = Get-AzRecoveryServicesVault -ResourceGroupName $resourceGroupName -Name $vaultName | ||
|
|
||
| $item = Get-AzRecoveryServicesBackupItem ` | ||
| -BackupManagementType AzureVM -WorkloadType AzureVM -VaultId $vault.ID ` | ||
| | Where-Object { $_.Name -match $vmName } | ||
|
|
||
| Assert-NotNull $item; | ||
|
|
||
| # Fetch an existing recovery point for the CSB item (no new backup is triggered). | ||
| # Use Get-QueryDateInUtc so the date window is recorded and replayed deterministically. | ||
| $from = Get-QueryDateInUtc $((Get-Date).AddDays(-2)) "CSBOlrFrom" | ||
| $to = Get-QueryDateInUtc $(Get-Date) "CSBOlrTo" | ||
| # Restrict to instant-restore capable tiers (Snapshot or SnapshotAndVaultStandard) so OLR can | ||
| # leverage the snapshot instead of a vault-tier restore. | ||
| $rps = @(Get-AzRecoveryServicesBackupRecoveryPoint ` | ||
| -VaultId $vault.ID ` | ||
| -Item $item ` | ||
| -StartDate $from ` | ||
| -EndDate $to ` | ||
| | Where-Object { $_.RecoveryPointTier -eq "Snapshot" }) | ||
|
|
||
| Assert-True { $rps.Count -gt 0 }; | ||
| $rp = $rps[0] | ||
|
|
||
| # The chosen recovery point must be a Snapshot tier recovery point. | ||
| Assert-True { $rp.RecoveryPointTier -eq "Snapshot" }; | ||
|
|
||
| # The recovery point should belong to the VM's (cross) subscription. | ||
| Assert-True { $rp.SourceResourceId -match $item.ContainerSubscriptionId }; | ||
|
|
||
| # Trigger OLR (Replace Existing): no -TargetResourceGroupName and no -TargetSubscriptionId. | ||
| # The storage account must be resolved in the VM's subscription (derived from rp.SourceResourceId). | ||
| # Capture the restore job first, then wait separately. Piping Restore directly into | ||
| # Wait-AzRecoveryServicesBackupJob causes the downstream cmdlet's BeginProcessing to reset the | ||
| # shared Logger.Instance, which breaks WriteDebug calls made during Restore's ProcessRecord. | ||
| $restoreJob = Restore-AzRecoveryServicesBackupItem ` | ||
| -VaultId $vault.ID ` | ||
| -VaultLocation $vault.Location ` | ||
| -RecoveryPoint $rp ` | ||
| -StorageAccountName $saName ` | ||
| -StorageAccountResourceGroupName $saResourceGroupName | ||
|
|
||
| $restoreJob = $restoreJob | Wait-AzRecoveryServicesBackupJob -VaultId $vault.ID | ||
|
|
||
| Assert-True { $restoreJob.Status -eq "Completed" }; | ||
| } | ||
| finally | ||
| { | ||
| # Cleanup | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: extract the "VM Subscription ID" propertyBag key into a named const — it's duplicated here and in GetPSAzureVmJobCrr and will drift.