diff --git a/src/azure-cli/azure/cli/command_modules/backup/_help.py b/src/azure-cli/azure/cli/command_modules/backup/_help.py index 21bc6b8dfdf..7eb7a23d3d0 100644 --- a/src/azure-cli/azure/cli/command_modules/backup/_help.py +++ b/src/azure-cli/azure/cli/command_modules/backup/_help.py @@ -276,6 +276,13 @@ --vault-name myRecoveryServicesVault \\ --policy-name DefaultPolicy \\ --vm "$(az vm show -g VMResourceGroup -n MyVm --query id)" + - name: Start protecting an Azure VM that resides in a different subscription than the Recovery Services vault (Cross Subscription Backup). Pass the complete ARM ID of the VM to the --vm parameter so its subscription, resource group and name are derived from it. + text: | + az backup protection enable-for-vm \\ + --resource-group myResourceGroup \\ + --vault-name myRecoveryServicesVault \\ + --policy-name DefaultPolicy \\ + --vm "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/VMResourceGroup/providers/Microsoft.Compute/virtualMachines/MyVm" """ helps['backup protection enable-for-azurefileshare'] = """ @@ -417,10 +424,26 @@ helps['backup restore restore-disks'] = """ type: command short-summary: Restore disks of the backed VM from the specified recovery point. +long-summary: > + For a Cross Subscription Backup (CSB) protected item, pass the native container name and item name + (the 'name' field from 'az backup container list' and 'az backup item list') instead of the friendly + name. This is also required when two VMs with the same friendly name but in different resource groups + are protected in the same vault - passing the friendly name in that case fails with a clear error. examples: - name: Restore disks of the backed VM from the specified recovery point. (autogenerated) text: az backup restore restore-disks --container-name MyContainer --item-name MyItem --resource-group MyResourceGroup --rp-name MyRp --storage-account mystorageaccount --vault-name MyVault crafted: true + - name: Restore disks of a Cross Subscription Backup protected VM to its original location (OLR). Use OriginalLocation restore mode and pass native container and item names. No target subscription is required - it is derived from the recovery point, and the staging storage account is resolved in the VM's subscription. + text: | + az backup restore restore-disks \\ + --resource-group MyVaultResourceGroup \\ + --vault-name MyVault \\ + --container-name "IaasVMContainer;iaasvmcontainerv2;VMResourceGroup;MyVm" \\ + --item-name "VM;iaasvmcontainerv2;VMResourceGroup;MyVm" \\ + --rp-name MyRp \\ + --restore-mode OriginalLocation \\ + --storage-account mystorageaccount \\ + --storage-account-resource-group MyStorageAccountResourceGroup """ helps['backup restore restore-azurefileshare'] = """ diff --git a/src/azure-cli/azure/cli/command_modules/backup/custom.py b/src/azure-cli/azure/cli/command_modules/backup/custom.py index f3e5eee7551..75bc7bafcf4 100644 --- a/src/azure-cli/azure/cli/command_modules/backup/custom.py +++ b/src/azure-cli/azure/cli/command_modules/backup/custom.py @@ -914,10 +914,6 @@ def enable_protection_for_vm(cmd, client, resource_group_name, vault_name, vm, p disk_list_setting=None, exclude_all_data_disks=None): from ..vm.operations.vm import VMShow vm_name, vm_rg = cust_help.get_resource_name_and_rg(resource_group_name, vm) - vm = VMShow(cli_ctx=cmd.cli_ctx)(command_args={ - 'resource_group': vm_rg, - 'vm_name': vm_name - }) vault = vaults_cf(cmd.cli_ctx).get(resource_group_name, vault_name) policy = show_policy(protection_policies_cf(cmd.cli_ctx), resource_group_name, vault_name, policy_name) @@ -928,12 +924,6 @@ def enable_protection_for_vm(cmd, client, resource_group_name, vault_name, vm, p if policy.properties.protected_items_count >= 1000: raise CLIError("Cannot configure backup for more than 1000 VMs per policy") - if vm.get('location', '').lower() != vault.location.lower(): - raise CLIError( - """ - The VM should be in the same location as that of the Recovery Services vault to enable protection. - """) - if policy.properties.backup_management_type != BackupManagementType.azure_iaas_vm.value: raise CLIError( """ @@ -941,26 +931,65 @@ def enable_protection_for_vm(cmd, client, resource_group_name, vault_name, vm, p Use the relevant get-default policy command and use it to protect the workload. """) - # Get protectable item. - protectable_item = _get_protectable_item_for_vm(cmd.cli_ctx, vault_name, resource_group_name, vm_name, vm_rg) - if protectable_item is None: - raise CLIError( - """ - The specified Azure Virtual Machine Not Found. Possible causes are - 1. VM does not exist - 2. The VM name or the Service name needs to be case sensitive - 3. VM is already Protected with same or other Vault. - Please Unprotect VM first and then try to protect it again. + # Cross Subscription Backup (CSB): when the VM is specified as a full ARM id that resides in a + # subscription different from the vault's, discovery (RefreshContainers/ListProtectableItems) - which + # only operates on the vault's subscription - cannot find the VM. In this case we skip discovery and + # construct the container uri, protected item uri and source resource id directly from the VM ARM id. + # The backend derives the VM's subscription from the source resource id and validates region/existence. + vault_subscription_id = get_subscription_id(cmd.cli_ctx) + vm_subscription_id = cust_help.get_subscription_from_id(vm) if is_valid_resource_id(vm) else None + is_cross_subscription = (vm_subscription_id is not None and + vm_subscription_id.lower() != vault_subscription_id.lower()) + + if is_cross_subscription: + # Validate the cross-subscription VM exists and resides in the same region as the vault. + # Discovery cannot run cross-subscription, so we fetch the VM directly in its own subscription + # (same idiom used for cross-sub vnet/storage lookups) to fail fast on a wrong ARM id or a + # region mismatch before calling the backend. + vm_resource = _get_vm_resource(cmd.cli_ctx, vm_subscription_id, vm_rg, vm_name) + if vm_resource.location.lower() != vault.location.lower(): + raise CLIError( + """ + The VM should be in the same location as that of the Recovery Services vault to enable protection. + """) - Please contact Microsoft for further assistance. - """) + container_uri = "IaasVMContainer;iaasvmcontainerv2;{};{}".format(vm_rg, vm_name) + item_uri = "vm;iaasvmcontainerv2;{};{}".format(vm_rg, vm_name) + vm_item_properties = _get_vm_item_properties_from_vm_id(vm) + vm_item_properties.policy_id = policy.id + vm_item_properties.source_resource_id = vm + else: + vm = VMShow(cli_ctx=cmd.cli_ctx)(command_args={ + 'resource_group': vm_rg, + 'vm_name': vm_name + }) - # Construct enable protection request object - container_uri = cust_help.get_protection_container_uri_from_id(protectable_item.id) - item_uri = cust_help.get_protectable_item_uri_from_id(protectable_item.id) - vm_item_properties = _get_vm_item_properties_from_vm_type(vm['type']) - vm_item_properties.policy_id = policy.id - vm_item_properties.source_resource_id = protectable_item.properties.virtual_machine_id + if vm.get('location', '').lower() != vault.location.lower(): + raise CLIError( + """ + The VM should be in the same location as that of the Recovery Services vault to enable protection. + """) + + # Get protectable item. + protectable_item = _get_protectable_item_for_vm(cmd.cli_ctx, vault_name, resource_group_name, vm_name, vm_rg) + if protectable_item is None: + raise CLIError( + """ + The specified Azure Virtual Machine Not Found. Possible causes are + 1. VM does not exist + 2. The VM name or the Service name needs to be case sensitive + 3. VM is already Protected with same or other Vault. + Please Unprotect VM first and then try to protect it again. + + Please contact Microsoft for further assistance. + """) + + # Construct enable protection request object + container_uri = cust_help.get_protection_container_uri_from_id(protectable_item.id) + item_uri = cust_help.get_protectable_item_uri_from_id(protectable_item.id) + vm_item_properties = _get_vm_item_properties_from_vm_type(vm['type']) + vm_item_properties.policy_id = policy.id + vm_item_properties.source_resource_id = protectable_item.properties.virtual_machine_id if disk_list_setting is not None and exclude_all_data_disks is not None: raise MutuallyExclusiveArgumentError(""" @@ -1061,6 +1090,10 @@ def list_items(cmd, client, resource_group_name, vault_name, container_name=None container_name, resource_group_name, vault_name, container_type) cust_help.validate_container(container) + if isinstance(container, list): + raise ValidationError("Multiple containers with same Friendly Name found. Please provide native " + "names instead. Native name can be obtained from the 'name' field in the " + "output of 'az backup container list'.") container_uri = container.name return [item for item in paged_items if @@ -1474,6 +1507,16 @@ def restore_disks(cmd, client, resource_group_name, vault_name, container_name, item_name, "AzureIaasVM", "VM", use_secondary_region) cust_help.validate_item(item) + if isinstance(item, list): + raise ValidationError("Found multiple backup items. Please provide native names instead.") + + # For Original Location Recovery (OLR) of a Cross Subscription Backup protected item, the disks are + # restored to the VM's original subscription, which may differ from the vault's subscription. Derive + # the container (VM) subscription from the protected item's sourceResourceId so that the target storage + # account is resolved in the correct subscription. No additional input is required from the customer. + if (restore_mode == "OriginalLocation" and item.properties.source_resource_id is not None): + target_subscription = cust_help.get_subscription_from_id(item.properties.source_resource_id) + recovery_point = show_recovery_point(cmd, recovery_points_cf(cmd.cli_ctx), resource_group_name, vault_name, container_name, item_name, rp_name, "AzureIaasVM", "VM", use_secondary_region) @@ -1724,9 +1767,9 @@ def show_job(cmd, client, resource_group_name, vault_name, name, use_secondary_r azure_region = secondary_region_map[vault_location] client = backup_crr_job_details_cf(cmd.cli_ctx) response = client.get(azure_region, CrrJobRequest(resource_id=vault.id, job_name=name)) - return cust_help.replace_min_value_in_subtask(response) + return cust_help.set_job_container_subscription_id(cust_help.replace_min_value_in_subtask(response)) response = client.get(vault_name, resource_group_name, name) - return cust_help.replace_min_value_in_subtask(response) + return cust_help.set_job_container_subscription_id(cust_help.replace_min_value_in_subtask(response)) def stop_job(client, resource_group_name, vault_name, name, use_secondary_region=None): @@ -1839,6 +1882,18 @@ def _get_crr_access_token(cmd, azure_region, vault_name, resource_group_name, co return crr_access_token +def _get_vm_resource(cli_ctx, vm_subscription, vm_resource_group, vm_name): + resources_client = get_mgmt_service_client(cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES, + subscription_id=vm_subscription).resources + vm_resource_namespace = 'Microsoft.Compute' + parent_resource_path = 'virtualMachines' + resource_type = '' + api_version = '2023-03-01' + + return resources_client.get(vm_resource_group, vm_resource_namespace, parent_resource_path, resource_type, + vm_name, api_version) + + def _get_vnet_object(cli_ctx, vnet_subscription, vnet_name, vnet_resource_group): resources_client = get_mgmt_service_client(cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES, subscription_id=vnet_subscription).resources diff --git a/src/azure-cli/azure/cli/command_modules/backup/custom_common.py b/src/azure-cli/azure/cli/command_modules/backup/custom_common.py index 1dff56b231a..29941f06e2e 100644 --- a/src/azure-cli/azure/cli/command_modules/backup/custom_common.py +++ b/src/azure-cli/azure/cli/command_modules/backup/custom_common.py @@ -123,7 +123,8 @@ def show_item(cmd, client, resource_group_name, vault_name, container_name, name else: if custom_help.is_native_name(name) and custom_help.is_native_name(container_name): client = protected_items_cf(cmd.cli_ctx) - return client.get(vault_name, resource_group_name, fabric_name, container_name, name) + item = client.get(vault_name, resource_group_name, fabric_name, container_name, name) + return custom_help.set_container_subscription_id(item) items = list_items(cmd, client, resource_group_name, vault_name, workload_type, container_name, container_type, use_secondary_region) @@ -158,6 +159,8 @@ def list_items(cmd, client, resource_group_name, vault_name, workload_type=None, client = backup_protected_items_crr_cf(cmd.cli_ctx) items = client.list(vault_name, resource_group_name, filter_string) paged_items = custom_help.get_list_from_paged_response(items) + for item in paged_items: + custom_help.set_container_subscription_id(item) if container_name: if custom_help.is_native_name(container_name): diff --git a/src/azure-cli/azure/cli/command_modules/backup/custom_help.py b/src/azure-cli/azure/cli/command_modules/backup/custom_help.py index 29edc991af9..ba13c40985c 100644 --- a/src/azure-cli/azure/cli/command_modules/backup/custom_help.py +++ b/src/azure-cli/azure/cli/command_modules/backup/custom_help.py @@ -668,6 +668,36 @@ def get_subscription_from_id(arm_id): return m.group(0) +def set_container_subscription_id(item): + # For an Azure VM backup item, surface the subscription of the protected VM (container) in the + # response as 'containerSubscriptionId'. It is parsed from the item's sourceResourceId, which for a + # Cross Subscription Backup item points to a subscription different from the vault's subscription. + if item is None or not hasattr(item, 'properties'): + return item + properties = item.properties + backup_management_type = getattr(properties, 'backup_management_type', None) + source_resource_id = getattr(properties, 'source_resource_id', None) + if (backup_management_type is not None and backup_management_type.lower() == 'azureiaasvm' and + source_resource_id): + properties.container_subscription_id = get_subscription_from_id(source_resource_id) + return item + + +def set_job_container_subscription_id(job): + # For an Azure VM backup/restore job, surface the subscription of the protected VM (container) in the + # response as 'containerSubscriptionId'. It is read from the job's extendedInfo property bag, which + # contains the "VM Subscription ID" for Cross Subscription Backup jobs. + if job is None or not hasattr(job, 'properties'): + return job + extended_info = getattr(job.properties, 'extended_info', None) + if extended_info is None: + return job + property_bag = getattr(extended_info, 'property_bag', None) + if property_bag and 'VM Subscription ID' in property_bag: + job.properties.container_subscription_id = property_bag['VM Subscription ID'] + return job + + def get_operation_id_from_header(header): parse_object = urlparse(header) return parse_object.path.split("/")[-1] diff --git a/src/azure-cli/azure/cli/command_modules/backup/tests/latest/recordings/test_backup_restore_multiple_containers_same_friendly_name.yaml b/src/azure-cli/azure/cli/command_modules/backup/tests/latest/recordings/test_backup_restore_multiple_containers_same_friendly_name.yaml new file mode 100644 index 00000000000..3e917c6aab8 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/backup/tests/latest/recordings/test_backup_restore_multiple_containers_same_friendly_name.yaml @@ -0,0 +1,147 @@ +interactions: +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + CommandName: + - backup restore restore-disks + Connection: + - keep-alive + ParameterSetName: + - -g -v -c -i --rp-name --storage-account + User-Agent: + - AZURECLI/2.88.0 azsdk-python-core/1.39.0 Python/3.12.10 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault?api-version=2025-08-01 + response: + body: + string: '{"location":"eastus2euap","name":"singhprab-csb-vault","etag":"W/\"datetime''2026-01-26T15%3A04%3A04.3196337Z''\"","properties":{"provisioningState":"Succeeded","privateEndpointStateForBackup":"None","privateEndpointStateForSiteRecovery":"None","backupStorageVersion":"V1","securitySettings":{"immutabilitySettings":{"state":"Disabled"},"softDeleteSettings":{"softDeleteRetentionPeriodInDays":14,"softDeleteState":"AlwaysON","enhancedSecurityState":"AlwaysON"},"multiUserAuthorization":"Disabled","sourceScanConfiguration":{"state":"Disabled"}},"redundancySettings":{"standardTierStorageRedundancy":"GeoRedundant","crossRegionRestore":"Enabled"},"regionOfChoiceSettings":{"status":"Disabled"},"secureScore":"Adequate","bcdrSecurityLevel":"Good","publicNetworkAccess":"Enabled","restoreSettings":{"crossSubscriptionRestoreSettings":{"crossSubscriptionRestoreState":"Enabled"}}},"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault","type":"Microsoft.RecoveryServices/vaults","sku":{"name":"RS0","tier":"Standard"}}' + headers: + cache-control: + - no-cache + content-length: + - '1115' + content-type: + - application/json; charset=utf-8 + date: + - Mon, 06 Jul 2026 11:23:24 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-ratelimit-remaining-subscription-global-reads: + - '16499' + x-msedge-ref: + - 'Ref A: D75F0B016473429CA2EBADE39AC9E706 Ref B: PNQ231110906034 Ref C: 2026-07-06T11:23:23Z' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + CommandName: + - backup restore restore-disks + Connection: + - keep-alive + ParameterSetName: + - -g -v -c -i --rp-name --storage-account + User-Agent: + - AZURECLI/2.88.0 azsdk-python-core/1.39.0 Python/3.12.10 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupProtectedItems?api-version=2025-02-01&%24filter=backupManagementType+eq+%27AzureIaasVM%27+and+itemType+eq+%27VM%27 + response: + body: + string: '{"value":[{"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;singhprab-ccy-rg-1c;testcvm123/protectedItems/VM;iaasvmcontainerv2;singhprab-ccy-rg-1c;testcvm123","name":"VM;iaasvmcontainerv2;singhprab-ccy-rg-1c;testcvm123","type":"Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems","properties":{"friendlyName":"testcvm123","virtualMachineId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-ccy-rg-1c/providers/Microsoft.Compute/virtualMachines/testcvm123","protectionStatus":"Healthy","protectionState":"Protected","healthStatus":"Passed","healthDetails":[{"code":400239,"title":"IaasVmHealthGreenDefault","message":"Backup + pre-check status of this virtual machine is OK.","recommendations":[]}],"lastBackupStatus":"Completed","lastBackupTime":"2026-07-06T08:11:25.4685645Z","protectedItemDataId":"1213862319043972","extendedProperties":{},"policyType":"V2","protectedItemType":"Microsoft.Compute/virtualMachines","backupManagementType":"AzureIaasVM","workloadType":"VM","containerName":"iaasvmcontainerv2;singhprab-ccy-rg-1c;testcvm123","sourceResourceId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-ccy-rg-1c/providers/Microsoft.Compute/virtualMachines/testcvm123","policyId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupPolicies/EnhancedPolicy","policyName":"EnhancedPolicy","lastRecoveryPoint":"2026-07-06T08:13:33.7958354Z","vaultId":"https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault","isArchiveEnabled":false,"softDeleteRetentionPeriod":0}},{"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;singhprab-csb-vault-rg;vm-test/protectedItems/VM;iaasvmcontainerv2;singhprab-csb-vault-rg;vm-test","name":"VM;iaasvmcontainerv2;singhprab-csb-vault-rg;vm-test","type":"Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems","properties":{"friendlyName":"vm-test","virtualMachineId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-csb-vault-rg/providers/Microsoft.Compute/virtualMachines/vm-test","protectionStatus":"Healthy","protectionState":"Protected","healthStatus":"Passed","healthDetails":[{"code":400239,"title":"IaasVmHealthGreenDefault","message":"Backup + pre-check status of this virtual machine is OK.","recommendations":[]}],"lastBackupStatus":"Completed","lastBackupTime":"2026-07-06T01:09:57.7646082Z","protectedItemDataId":"1213860894808134","policyType":"V1","protectedItemType":"Microsoft.Compute/virtualMachines","backupManagementType":"AzureIaasVM","workloadType":"VM","containerName":"iaasvmcontainerv2;singhprab-csb-vault-rg;vm-test","sourceResourceId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-csb-vault-rg/providers/Microsoft.Compute/virtualMachines/vm-test","policyId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupPolicies/DefaultPolicy","policyName":"DefaultPolicy","lastRecoveryPoint":"2026-07-06T01:10:06.7325253Z","vaultId":"https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault","isArchiveEnabled":false,"softDeleteRetentionPeriod":0}},{"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;singhprab-ecy-aug25-drill-rg;singhprab-csb-vm/protectedItems/VM;iaasvmcontainerv2;singhprab-ecy-aug25-drill-rg;singhprab-csb-vm","name":"VM;iaasvmcontainerv2;singhprab-ecy-aug25-drill-rg;singhprab-csb-vm","type":"Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems","properties":{"friendlyName":"singhprab-csb-vm","virtualMachineId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-ecy-aug25-drill-rg/providers/Microsoft.Compute/virtualMachines/singhprab-csb-vm","protectionStatus":"Healthy","protectionState":"ProtectionStopped","healthStatus":"Passed","lastBackupStatus":"","lastBackupTime":"2001-01-01T00:00:00Z","protectedItemDataId":"1213861779090887","policyType":"V1","protectedItemType":"Microsoft.Compute/virtualMachines","backupManagementType":"AzureIaasVM","workloadType":"VM","containerName":"iaasvmcontainerv2;singhprab-ecy-aug25-drill-rg;singhprab-csb-vm","sourceResourceId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-ecy-aug25-drill-rg/providers/Microsoft.Compute/virtualMachines/singhprab-csb-vm","policyId":"","policyName":"","vaultId":"https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault","isArchiveEnabled":false,"softDeleteRetentionPeriod":0}},{"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;singhprab-rg-1c;testcvm123/protectedItems/VM;iaasvmcontainerv2;singhprab-rg-1c;testcvm123","name":"VM;iaasvmcontainerv2;singhprab-rg-1c;testcvm123","type":"Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems","properties":{"friendlyName":"testcvm123","virtualMachineId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-rg-1c/providers/Microsoft.Compute/virtualMachines/testcvm123","protectionStatus":"Unhealthy","protectionState":"Protected","healthStatus":"Passed","healthDetails":[{"code":400239,"title":"IaasVmHealthGreenDefault","message":"Backup + pre-check status of this virtual machine is OK.","recommendations":[]}],"lastBackupStatus":"Failed","lastBackupTime":"2026-07-06T08:11:28.6128677Z","protectedItemDataId":"1213862310952975","extendedProperties":{},"policyType":"V2","protectedItemType":"Microsoft.Compute/virtualMachines","backupManagementType":"AzureIaasVM","workloadType":"VM","containerName":"iaasvmcontainerv2;singhprab-rg-1c;testcvm123","sourceResourceId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-rg-1c/providers/Microsoft.Compute/virtualMachines/testcvm123","policyId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupPolicies/EnhancedPolicy","policyName":"EnhancedPolicy","vaultId":"https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault","isArchiveEnabled":false,"softDeleteRetentionPeriod":0}}]}' + headers: + cache-control: + - no-cache + content-length: + - '7403' + content-type: + - application/json; charset=utf-8 + date: + - Mon, 06 Jul 2026 11:23:26 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-operation-identifier: + - tenantId=72f988bf-86f1-41af-91ab-2d7cd011db47,objectId=f17e256d-a4e1-4dae-8520-844b6aa54acb/eastasia/a41ba39d-34c2-47ca-92ee-4fd71cabfb02 + x-ms-ratelimit-remaining-subscription-resource-requests: + - '249' + x-msedge-ref: + - 'Ref A: FAE542156D794D56A96355D3D9877CDD Ref B: PNQ231110909042 Ref C: 2026-07-06T11:23:25Z' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + CommandName: + - backup restore restore-disks + Connection: + - keep-alive + ParameterSetName: + - -g -v -c -i --rp-name --storage-account + User-Agent: + - AZURECLI/2.88.0 azsdk-python-core/1.39.0 Python/3.12.10 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupProtectionContainers?api-version=2025-02-01&%24filter=backupManagementType+eq+%27AzureIaasVM%27+and+friendlyName+eq+%27testcvm123%27+and+status+eq+%27Registered%27 + response: + body: + string: '{"value":[{"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;singhprab-ccy-rg-1c;testcvm123","name":"IaasVMContainer;iaasvmcontainerv2;singhprab-ccy-rg-1c;testcvm123","type":"Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers","properties":{"virtualMachineId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-ccy-rg-1c/providers/Microsoft.Compute/virtualMachines/testcvm123","virtualMachineVersion":"Compute","resourceGroup":"singhprab-ccy-rg-1c","friendlyName":"testcvm123","backupManagementType":"AzureIaasVM","registrationStatus":"Registered","healthStatus":"Healthy","containerType":"Microsoft.Compute/virtualMachines","protectableObjectType":"Microsoft.Compute/virtualMachines"}},{"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sgholapDMTesting-rg/providers/Microsoft.RecoveryServices/vaults/singhprab-csb-vault/backupFabrics/Azure/protectionContainers/IaasVMContainer;iaasvmcontainerv2;singhprab-rg-1c;testcvm123","name":"IaasVMContainer;iaasvmcontainerv2;singhprab-rg-1c;testcvm123","type":"Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers","properties":{"virtualMachineId":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/singhprab-rg-1c/providers/Microsoft.Compute/virtualMachines/testcvm123","virtualMachineVersion":"Compute","resourceGroup":"singhprab-rg-1c","friendlyName":"testcvm123","backupManagementType":"AzureIaasVM","registrationStatus":"Registered","healthStatus":"Healthy","containerType":"Microsoft.Compute/virtualMachines","protectableObjectType":"Microsoft.Compute/virtualMachines"}}]}' + headers: + cache-control: + - no-cache + content-length: + - '1803' + content-type: + - application/json; charset=utf-8 + date: + - Mon, 06 Jul 2026 11:23:27 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-operation-identifier: + - tenantId=72f988bf-86f1-41af-91ab-2d7cd011db47,objectId=f17e256d-a4e1-4dae-8520-844b6aa54acb/eastasia/69dcb72d-64cd-413b-87a0-d6e391aef4c3 + x-ms-ratelimit-remaining-subscription-resource-requests: + - '249' + x-msedge-ref: + - 'Ref A: 9C9F3C692438428BB8620FAB0CB2481D Ref B: PNQ231110907025 Ref C: 2026-07-06T11:23:26Z' + status: + code: 200 + message: OK +version: 1 diff --git a/src/azure-cli/azure/cli/command_modules/backup/tests/latest/test_backup_commands.py b/src/azure-cli/azure/cli/command_modules/backup/tests/latest/test_backup_commands.py index a15b101d2d0..8c18ff39657 100644 --- a/src/azure-cli/azure/cli/command_modules/backup/tests/latest/test_backup_commands.py +++ b/src/azure-cli/azure/cli/command_modules/backup/tests/latest/test_backup_commands.py @@ -5,6 +5,7 @@ import json from datetime import datetime, timedelta +from types import SimpleNamespace import unittest import time import random @@ -400,6 +401,12 @@ def test_backup_item(self, resource_group, vault_name, vm1, vm2, policy_name): self.assertIn(vm1.lower(), item1_json['properties']['virtualMachineId'].lower()) self.assertIn(self.kwargs['default'].lower(), item1_json['properties']['policyId'].lower()) + # containerSubscriptionId is surfaced for Azure VM items and is parsed from sourceResourceId. + # It equals the subscription that hosts the protected VM (which, for a Cross Subscription Backup + # item, differs from the vault's subscription). + self.assertEqual(item1_json['properties']['containerSubscriptionId'], + item1_json['properties']['sourceResourceId'].split('/')[2]) + self.kwargs['container1_fullname'] = self.cmd('backup container show --backup-management-type AzureIaasVM -n {vm1} -v {vault} -g {rg} --query name').get_output_in_json() self.cmd('backup item show --workload-type VM -g {rg} -v {vault} -c {container1_fullname} -n {vm1}', checks=[ @@ -664,6 +671,220 @@ def test_backup_diskaccess_restore(self): self.cmd('vm delete -g {rg} -n {target_vm_name} --yes') + # Prerequisites (needed only for re-recording this test live): two VMs with the SAME friendly name + # but in different resource groups, both protected in the same vault. This produces multiple + # registered containers with the same friendly name, which cannot be provisioned via the standard + # Preparers (they generate unique names), hence this test targets pre-existing resources and is + # validated via its recording in playback. + def test_backup_restore_multiple_containers_same_friendly_name(self): + self.kwargs.update({ + 'rg': 'sgholapDMTesting-rg', + 'vault': 'singhprab-csb-vault', + 'container_name': 'testcvm123', + 'item_name': 'testcvm123', + 'sa': 'smorteylcdtplane1', + 'rp': '1198973670844227' + }) + + # Passing the friendly container name resolves to multiple containers (same friendly name in + # different resource groups). The command must fail with a clear validation error instead of + # crashing, asking the customer to provide the native container name. + self.cmd('backup restore restore-disks -g {rg} -v {vault} -c {container_name} -i {item_name} ' + '--rp-name {rp} --storage-account {sa}', expect_failure=True) + + + # ===================================================================================================== + # Cross Subscription Backup (CSB) live-only tests. + # + # CSB requires a VM in a subscription different from the vault's subscription. This cannot be modelled + # with the standard Preparers (they provision in a single subscription), and it cannot be validated in + # playback because the test framework sanitizes every subscription id to a single dummy value, which + # collapses the "VM sub != vault sub" distinction the CSB code branches on. These tests are therefore + # marked @live_only (skipped in CI/playback) and are intended to be run live by the team validating CSB, + # against a pre-existing cross-subscription setup. Fill in the placeholder values below before running. + # ===================================================================================================== + + @live_only() + # Test Owner @ singhprab + @ResourceGroupPreparer(name_prefix="AzureBackupRG_clitest_", location="eastasia") + @VaultPreparer() + def test_backup_csb_enable_for_vm(self, resource_group, vault_name): + # Cross Subscription Backup (CSB): the VM lives in a subscription different from the vault's. + # A fresh vault is created on the fly (VaultPreparer) each run so there is no leftover state + # (soft-deleted items etc.) to interfere. Only the cross-subscription VM is a pre-existing resource + # (there is no Preparer that can provision a VM in a different subscription). At the end, protection + # is disabled so the VM is freed and can be re-protected on the next run. + # + # If this test fails because the VM below no longer exists (deleted, or you don't have access), + # create a new one in a subscription DIFFERENT from the vault's, in the SAME region as the vault + # (eastasia, per the ResourceGroupPreparer above), then update vm_id / vm_sub / vm_name accordingly: + self.kwargs.update({ + 'rg': resource_group, + 'vault': vault_name, + # Full ARM id of a pre-existing VM whose subscription differs from the vault's subscription. + # It must be in the same region as the vault (see ResourceGroupPreparer location above). + 'vm_id': '/subscriptions/80abcfe3-b410-42b2-983f-df23cba781dc/resourceGroups/singhprab-rg-1c/providers/Microsoft.Compute/virtualMachines/singhprab-ps-vm7', + # The VM's subscription id (same as the one embedded in vm_id above); used for assertions. + 'vm_sub': '80abcfe3-b410-42b2-983f-df23cba781dc', + # Friendly name of the VM (used to locate the protected item after enabling). + 'vm_name': 'singhprab-ps-vm7', + 'policy1': self.create_random_name('clitest-csb-pol1', 24), + 'policy2': self.create_random_name('clitest-csb-pol2', 24) + }) + + # Build two Enhanced (V2) hourly policies from the vault's built-in EnhancedPolicy template: + # policy1 with a 4-hour backup frequency and policy2 with an 8-hour backup frequency. + enhanced_policy = self.cmd('backup policy show -g {rg} -v {vault} -n EnhancedPolicy').get_output_in_json() + + enhanced_policy['name'] = self.kwargs['policy1'] + enhanced_policy['properties']['schedulePolicy']['hourlySchedule']['interval'] = 4 + self.kwargs['policy1_json'] = json.dumps(enhanced_policy) + self.cmd("backup policy create --backup-management-type AzureIaasVM -g {rg} -v {vault} -n {policy1} " + "--policy '{policy1_json}'", checks=[ + self.check('name', '{policy1}'), + self.check('properties.schedulePolicy.scheduleRunFrequency', 'Hourly'), + self.check('properties.schedulePolicy.hourlySchedule.interval', 4) + ]) + + enhanced_policy['name'] = self.kwargs['policy2'] + enhanced_policy['properties']['schedulePolicy']['hourlySchedule']['interval'] = 8 + self.kwargs['policy2_json'] = json.dumps(enhanced_policy) + self.cmd("backup policy create --backup-management-type AzureIaasVM -g {rg} -v {vault} -n {policy2} " + "--policy '{policy2_json}'", checks=[ + self.check('name', '{policy2}'), + self.check('properties.schedulePolicy.scheduleRunFrequency', 'Hourly'), + self.check('properties.schedulePolicy.hourlySchedule.interval', 8) + ]) + + # Configure Backup for the cross-subscription VM by passing its full ARM id (scenario #6). + self.cmd('backup protection enable-for-vm -g {rg} -v {vault} --vm {vm_id} -p {policy1}', checks=[ + self.check("properties.entityFriendlyName", '{vm_name}'), + self.check("properties.operation", "ConfigureBackup"), + self.check("properties.status", "Completed") + ]) + + # The protected item must belong to the VM's subscription (CSB), surfaced as containerSubscriptionId. + item = self.cmd('backup item show --backup-management-type AzureIaasVM --workload-type VM ' + '-g {rg} -v {vault} -c {vm_name} -n {vm_name}').get_output_in_json() + self.assertEqual(item['properties']['containerSubscriptionId'], self.kwargs['vm_sub']) + self.assertEqual(item['properties']['sourceResourceId'].split('/')[2], self.kwargs['vm_sub']) + + # Modify Protection: switch the already-protected CSB item to a different policy (scenario #7). + self.cmd('backup item set-policy --backup-management-type AzureIaasVM --workload-type VM ' + '-g {rg} -v {vault} -c {vm_name} -n {vm_name} -p {policy2}', checks=[ + self.check("properties.entityFriendlyName", '{vm_name}'), + self.check("properties.operation", "ConfigureBackup"), + self.check("properties.status", "Completed") + ]) + + # Disable protection (and delete backup data) so the cross-subscription VM is freed for the next run. + # Use the VM friendly name for the container/item: for a CSB item the raw containerName is not in the + # format 'backup protection disable' accepts (BMSUserErrorContainerNameIncorrectFormat). + self.cmd('backup protection disable --backup-management-type AzureIaasVM --workload-type VM ' + '-g {rg} -v {vault} -c {vm_name} -i {vm_name} --delete-backup-data true --yes') + + @live_only() + # Test Owner @ singhprab + @ResourceGroupPreparer(name_prefix="AzureBackupRG_clitest_", location="eastasia") + @VaultPreparer() + def test_backup_csb_restore_olr(self, resource_group, vault_name): + # Cross Subscription Backup (CSB) Original Location Recovery (OLR): the VM lives in a subscription + # different from the vault's. This test is fully self-contained (like test_backup_csb_enable_for_vm): + # a fresh vault is created (VaultPreparer), the cross-subscription VM is protected, an on-demand + # backup is taken, and its recovery point is restored back to the original location. Only the + # cross-subscription VM and its staging storage account are pre-existing (no Preparer can provision + # resources in a different subscription). At the end, protection is disabled to free the VM. + # + # If this test fails because the VM/storage account below no longer exist (deleted, or you don't + # have access), create new ones in a subscription DIFFERENT from the vault's, in the SAME region as + # the vault (eastasia, per ResourceGroupPreparer above), then update the values below: + self.kwargs.update({ + 'rg': resource_group, + 'vault': vault_name, + # Full ARM id of a pre-existing VM whose subscription differs from the vault's subscription. + # It must be in the same region as the vault (see ResourceGroupPreparer location above). + 'vm_id': '/subscriptions/80abcfe3-b410-42b2-983f-df23cba781dc/resourceGroups/singhprab-rg-1c/providers/Microsoft.Compute/virtualMachines/singhprab-ps-vm8', + # The VM's subscription id (same as the one embedded in vm_id above); used for assertions. + 'vm_sub': '80abcfe3-b410-42b2-983f-df23cba781dc', + # Friendly name of the VM (used as the container/item friendly name after enabling). + 'vm_name': 'singhprab-ps-vm8', + # The VM's own resource group (embedded in the native container/item names used for restore). + 'vm_rg': 'singhprab-rg-1c', + # Staging storage account for OLR - must reside in the VM's (cross) subscription and same region. + 'sa': 'ssinghprabsa', + 'sa_rg': 'singhprab-rg-1c', + 'policy': self.create_random_name('clitest-csb-pol', 24) + }) + # Native container/item names for the CSB VM. restore-disks must be called with native names: for a + # cross-subscription container, resolving it by friendly name is unreliable, so we pass the fully + # qualified names (which skip the friendly-name lookup). Format matches 'az backup container/item list'. + self.kwargs['csb_container'] = 'IaasVMContainer;iaasvmcontainerv2;{};{}'.format( + self.kwargs['vm_rg'], self.kwargs['vm_name']) + self.kwargs['csb_item'] = 'VM;iaasvmcontainerv2;{};{}'.format( + self.kwargs['vm_rg'], self.kwargs['vm_name']) + + # Build an Enhanced (V2) hourly policy from the vault's built-in EnhancedPolicy template. + enhanced_policy = self.cmd('backup policy show -g {rg} -v {vault} -n EnhancedPolicy').get_output_in_json() + enhanced_policy['name'] = self.kwargs['policy'] + self.kwargs['policy_json'] = json.dumps(enhanced_policy) + self.cmd("backup policy create --backup-management-type AzureIaasVM -g {rg} -v {vault} -n {policy} " + "--policy '{policy_json}'", checks=[self.check('name', '{policy}')]) + + # Configure Backup for the cross-subscription VM by passing its full ARM id. + self.cmd('backup protection enable-for-vm -g {rg} -v {vault} --vm {vm_id} -p {policy}', checks=[ + self.check("properties.entityFriendlyName", '{vm_name}'), + self.check("properties.operation", "ConfigureBackup"), + self.check("properties.status", "Completed") + ]) + + # Confirm the protected item belongs to the VM's subscription (CSB). + item = self.cmd('backup item show --backup-management-type AzureIaasVM --workload-type VM ' + '-g {rg} -v {vault} -c {vm_name} -n {vm_name}').get_output_in_json() + self.assertEqual(item['properties']['containerSubscriptionId'], self.kwargs['vm_sub']) + + # Take an on-demand backup so there is a recovery point to restore from. + self.kwargs['retain_date'] = (datetime.utcnow() + timedelta(days=30)).strftime('%d-%m-%Y') + self.kwargs['backup_job'] = self.cmd('backup protection backup-now --backup-management-type AzureIaasVM ' + '--workload-type VM -g {rg} -v {vault} -c {vm_name} -i {vm_name} ' + '--retain-until {retain_date} --query name').get_output_in_json() + self.cmd('backup job wait -g {rg} -v {vault} -n {backup_job}') + + # Get the recovery point produced by the on-demand backup. + self.kwargs['rp'] = self.cmd('backup recoverypoint list --backup-management-type AzureIaasVM ' + '--workload-type VM -g {rg} -v {vault} -c {vm_name} -i {vm_name} ' + '--query [0].name').get_output_in_json() + + # Original Location Recovery for a CSB protected item: no --target-resource-group and no + # --target-subscription-id. The container subscription is derived from the item's sourceResourceId, + # and the staging storage account is resolved in that subscription (scenario #8). Native container/ + # item names are used because friendly-name resolution is unreliable for cross-subscription containers. + self.kwargs['restore_job'] = self.cmd( + 'backup restore restore-disks -g {rg} -v {vault} -c {csb_container} -i {csb_item} ' + '--rp-name {rp} --restore-mode OriginalLocation --storage-account {sa} ' + '--storage-account-resource-group {sa_rg}', checks=[ + self.check("properties.operation", "Restore"), + self.check("properties.status", "InProgress") + ]).get_output_in_json()['name'] + self.cmd('backup job wait -g {rg} -v {vault} -n {restore_job}') + self.cmd('backup job show -g {rg} -v {vault} -n {restore_job}', checks=[ + self.check("properties.operation", "Restore"), + self.check("properties.status", "Completed") + ]) + + # For a CSB job, containerSubscriptionId is populated from extendedInfo and must match the VM's + # subscription. Validate it on both the on-demand backup job and the restore job (scenario #14). + backup_job = self.cmd('backup job show -g {rg} -v {vault} -n {backup_job}').get_output_in_json() + self.assertEqual(backup_job['properties']['containerSubscriptionId'], self.kwargs['vm_sub']) + restore_job = self.cmd('backup job show -g {rg} -v {vault} -n {restore_job}').get_output_in_json() + self.assertEqual(restore_job['properties']['containerSubscriptionId'], self.kwargs['vm_sub']) + + # Disable protection (and delete backup data) so the cross-subscription VM is freed for the next run. + # Use the VM friendly name: for a CSB item the raw containerName is not in the format + # 'backup protection disable' accepts (BMSUserErrorContainerNameIncorrectFormat). + self.cmd('backup protection disable --backup-management-type AzureIaasVM --workload-type VM ' + '-g {rg} -v {vault} -c {vm_name} -i {vm_name} --delete-backup-data true --yes') + + @AllowLargeResponse() @ResourceGroupPreparer(name_prefix="AzureBackupRG_clitest_", location="eastus2euap") @ResourceGroupPreparer(name_prefix="AzureBackupRG_clitest_", parameter_name="target_resource_group", location="eastus2euap") @@ -1681,3 +1902,48 @@ def test_vault_soft_delete_misc_operations(self, resource_group, resource_group_ # Test listing deleted vaults with location filter deleted_vaults = self.cmd('backup deleted-vault list --location {location}').get_output_in_json() self.assertIsInstance(deleted_vaults, list) + + +class BackupJobContainerSubscriptionIdTests(unittest.TestCase): + """Unit tests for set_job_container_subscription_id, which surfaces containerSubscriptionId on + 'az backup job show' by reading the 'VM Subscription ID' key from the job's extendedInfo property bag + (populated for Cross Subscription Backup jobs). These guard the property-bag key name/parsing without + requiring a live cross-subscription setup.""" + + @staticmethod + def _make_job(property_bag=None, with_extended_info=True, with_properties=True): + if not with_properties: + return SimpleNamespace() + extended_info = SimpleNamespace(property_bag=property_bag) if with_extended_info else None + properties = SimpleNamespace(extended_info=extended_info, container_subscription_id=None) + return SimpleNamespace(properties=properties) + + def test_sets_container_subscription_id_from_property_bag(self): + from azure.cli.command_modules.backup.custom_help import set_job_container_subscription_id + vm_sub = '00000000-0000-0000-0000-000000000000' + job = self._make_job(property_bag={'VM Subscription ID': vm_sub}) + result = set_job_container_subscription_id(job) + self.assertEqual(result.properties.container_subscription_id, vm_sub) + + def test_leaves_container_subscription_id_unset_when_key_absent(self): + from azure.cli.command_modules.backup.custom_help import set_job_container_subscription_id + job = self._make_job(property_bag={'Some Other Key': 'value'}) + result = set_job_container_subscription_id(job) + self.assertIsNone(result.properties.container_subscription_id) + + def test_handles_missing_property_bag_and_extended_info(self): + from azure.cli.command_modules.backup.custom_help import set_job_container_subscription_id + job_no_bag = self._make_job(property_bag=None) + self.assertIsNone(set_job_container_subscription_id(job_no_bag).properties.container_subscription_id) + job_no_ext = self._make_job(with_extended_info=False) + self.assertIsNone(set_job_container_subscription_id(job_no_ext).properties.container_subscription_id) + + def test_handles_job_without_properties_or_none(self): + from azure.cli.command_modules.backup.custom_help import set_job_container_subscription_id + self.assertIsNone(set_job_container_subscription_id(None)) + job = self._make_job(with_properties=False) + self.assertIs(set_job_container_subscription_id(job), job) + + +if __name__ == '__main__': + unittest.main()