Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
aeee75c
Added support for cost management stetings
vidyadharijami Jul 17, 2026
48e4877
Fix az backup AFS live tests: BCDR policy tags + StorageV2, resilient…
zubairabid Jul 17, 2026
b23fb85
Fix backup live tests: tag VM-test storage accounts + bump resource-g…
zubairabid Jul 20, 2026
dc8e171
Added test for cost management settings
vidyadharijami Jul 21, 2026
303c096
Merge branch 'zubair/maintenance' of https://github.com/zubairabid/az…
vidyadharijami Jul 21, 2026
6206e4d
Merge branch 'zubair/maintenance' of https://github.com/zubairabid/az…
vidyadharijami Jul 21, 2026
d953138
Fix flake8 lint in cost-management vault changes (E231/W291/E302/E203)
zubairabid Jul 24, 2026
9327317
Fix test_backup_encryption CMK auth: create key vault in RBAC mode
zubairabid Jul 24, 2026
5b203e4
Refresh VM backup live-test recordings (passing runs on recovered can…
zubairabid Jul 24, 2026
3f06c94
Re-record control-plane backup tests for recoveryservices SDK 4.1.0 (…
zubairabid Jul 24, 2026
5d1e0a5
Update test_backup_wl_reconfigure and test_backup_wl_sql_crr recordin…
zubairabid Jul 24, 2026
dfd7d1c
Update backup/restore test recordings for recoveryservices SDK 4.1.0
zubairabid Jul 24, 2026
095c5ce
Sanitize test subscription id in backup recordings
zubairabid Jul 25, 2026
4008d99
Re-record test_backup_disk_exclusion for recoveryservices SDK 4.1.0
zubairabid Jul 25, 2026
4eec1da
Re-record backup tests (job, rp, scenario) for recoveryservices SDK 4…
zubairabid Jul 26, 2026
f97e649
Re-record backup restore tests (restore, CrossZonalRestore, restore_w…
zubairabid Jul 26, 2026
ba6e8f0
Merge pull request #1 from zubairabid/vijami/clichanges
vidyadharijami Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/azure-cli/azure/cli/command_modules/backup/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
allowed_rehyd_priority_type = ['Standard', 'High']
allowed_softdelete_options = ['Enable', 'Disable', 'AlwaysOn']
allowed_immutability_options = ['Disabled', 'Locked', 'Unlocked']
allowed_granularitylevel_options = ['VaultLevel', 'ProtectedItemLevel', 'ProtectedItemWithParentTag']
enable_disable_options = ['Enable', 'Disable']
enable_disable_permadisable_options = ['Enable', 'Disable', 'PermanentlyDisable']
allowed_disk_access_options = ['EnablePrivateAccessForAllDisks', 'EnablePublicAccessForAllDisks', 'SameAsOnSourceDisks']
Expand Down Expand Up @@ -108,6 +109,7 @@ def load_arguments(self, _):
c.argument('azure_monitor_alerts_for_job_failures', options_list=['--job-failure-alerts'], arg_type=get_enum_type(enable_disable_options), help='Use this property to specify whether built-in Azure Monitor alerts should be received for every job failure.')
c.argument('immutability_state', arg_type=get_enum_type(allowed_immutability_options), help='Use this parameter to configure immutability settings for the vault. By default, immutability is "Disabled" for the vault. "Unlocked" means that immutability is enabled for the vault and can be reversed. "Locked" means that immutability is enabled for the vault and cannot be reversed.')
c.argument('cross_subscription_restore_state', arg_type=get_enum_type(enable_disable_permadisable_options), help='Use this parameter to configure cross subscription restore settings for the vault. By default, the property is "Enabled" for the vault.')
c.argument('cost_management_granularity', arg_type=get_enum_type(allowed_granularitylevel_options), help='Use this parameter to configure cost management settings for the vault. By default, the property is "VaultLevel" for the vault.')
# TODO May add the soft_delete_retention_period_in_days parameter later. The other will not be exposed.
# c.argument('soft_delete_state', options_list=['--soft-delete-state', '--soft-delete-feature-state'], arg_type=get_enum_type(allowed_softdelete_options), help='Set soft-delete feature state for a Recovery Services Vault.')
# c.argument('soft_delete_retention_period_in_days', type=int, options_list=['--soft-delete-duration'], help='Set soft-delete retention duration time in days for a Recovery Services Vault.')
Expand All @@ -119,6 +121,7 @@ def load_arguments(self, _):
c.argument('azure_monitor_alerts_for_job_failures', options_list=['--job-failure-alerts'], arg_type=get_enum_type(enable_disable_options), help='Use this property to specify whether built-in Azure Monitor alerts should be received for every job failure.')
c.argument('immutability_state', arg_type=get_enum_type(allowed_immutability_options), help='Use this parameter to configure immutability settings for the vault. By default, immutability is "Disabled" for the vault. "Unlocked" means that immutability is enabled for the vault and can be reversed. "Locked" means that immutability is enabled for the vault and cannot be reversed.')
c.argument('cross_subscription_restore_state', arg_type=get_enum_type(enable_disable_permadisable_options), help='Use this parameter to configure cross subscription restore settings for the vault. By default, the property is "Enabled" for the vault.')
c.argument('cost_management_granularity', arg_type=get_enum_type(allowed_granularitylevel_options), help='Use this parameter to configure cost management settings for the vault. By default, the property is "VaultLevel" for the vault.')
# TODO Discussion with Rishav once Enhanced Soft Delete is in place. We can only expose the latter, and might have to disable it from vaultconfig API
# c.argument('soft_delete_state', options_list=['--soft-delete-state', '--soft-delete-feature-state'], arg_type=get_enum_type(allowed_softdelete_options), help='Set soft-delete feature state for a Recovery Services Vault.')
# c.argument('soft_delete_retention_period_in_days', type=int, options_list=['--soft-delete-duration'], help='Set soft-delete retention duration time in days for a Recovery Services Vault.')
Expand Down
48 changes: 41 additions & 7 deletions src/azure-cli/azure/cli/command_modules/backup/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
CmkKeyVaultProperties, CmkKekIdentity, VaultPropertiesEncryption, UserIdentity, MonitoringSettings, \
AzureMonitorAlertSettings, ClassicAlertSettings, SecuritySettings, ImmutabilitySettings, RestoreSettings, \
CrossSubscriptionRestoreSettings, DeletedVaultUndeleteInputProperties, DeletedVaultUndeleteInput, \
SoftDeleteSettings
SoftDeleteSettings, CostManagementSettings, ImmutabilityConfiguration
from azure.mgmt.recoveryservicesbackup.activestamp.models import ProtectedItemResource, \
AzureIaaSComputeVMProtectedItem, AzureIaaSClassicComputeVMProtectedItem, ProtectionState, IaasVMBackupRequest, \
BackupRequestResource, IaasVMRestoreRequest, RestoreRequestResource, BackupManagementType, WorkloadType, \
Expand Down Expand Up @@ -156,7 +156,7 @@
def update_vault(cmd, client, vault_name, resource_group_name, tags=None,
public_network_access=None, immutability_state=None, cross_subscription_restore_state=None,
classic_alerts=None, azure_monitor_alerts_for_job_failures=None, tenant_id=None,
backup_storage_redundancy=None, cross_region_restore_flag=None):
backup_storage_redundancy=None, cross_region_restore_flag=None, cost_management_granularity=None):
try:
existing_vault = client.get(resource_group_name, vault_name)
except CoreResourceNotFoundError:
Expand All @@ -175,6 +175,9 @@ def update_vault(cmd, client, vault_name, resource_group_name, tags=None,
if cross_subscription_restore_state is not None:
patchvault.properties.restore_settings = _get_vault_restore_settings(cross_subscription_restore_state)

if cost_management_granularity is not None:
patchvault.properties.cost_management_settings = _get_vault_cost_management_settings(cost_management_granularity, existing_vault)

if classic_alerts is not None or azure_monitor_alerts_for_job_failures is not None:
patchvault.properties.monitoring_settings = _get_vault_monitoring_settings(azure_monitor_alerts_for_job_failures,
classic_alerts, existing_vault)
Expand Down Expand Up @@ -208,17 +211,21 @@ def update_vault(cmd, client, vault_name, resource_group_name, tags=None,
# Import SoftDeleteSettings, args in create_vault and _get_vault_security_settings
def create_vault(cmd, client, vault_name, resource_group_name, location, tags=None,
public_network_access=None, immutability_state=None, cross_subscription_restore_state=None,
classic_alerts=None, azure_monitor_alerts_for_job_failures=None):
classic_alerts=None, azure_monitor_alerts_for_job_failures=None, cost_management_granularity=None):
try:
client.get(resource_group_name, vault_name)
logger.warning("You are using the az backup vault create command to update vault properties. Please "
"note that this is not officially supported, and can also reset some vault properties "
"to their default values. It is recommended to use az backup vault update instead.")

# If the vault exists, we move to the update flow instead
return update_vault(cmd, client, vault_name, resource_group_name, tags, public_network_access,
immutability_state, cross_subscription_restore_state, classic_alerts,
azure_monitor_alerts_for_job_failures)
return update_vault(cmd, client, vault_name, resource_group_name, tags=tags,
public_network_access=public_network_access,
immutability_state=immutability_state,
cross_subscription_restore_state=cross_subscription_restore_state,
classic_alerts=classic_alerts,
azure_monitor_alerts_for_job_failures=azure_monitor_alerts_for_job_failures,
cost_management_granularity=cost_management_granularity)
except CoreResourceNotFoundError:
vault_properties = VaultProperties()

Expand All @@ -241,6 +248,8 @@ def create_vault(cmd, client, vault_name, resource_group_name, location, tags=No
if cross_subscription_restore_state is not None:
vault_properties.restore_settings = _get_vault_restore_settings(cross_subscription_restore_state)

vault_properties.cost_management_settings = _get_vault_cost_management_settings(cost_management_granularity)

vault = Vault(location=location, sku=vault_sku, properties=vault_properties, tags=tags)

return client.begin_create_or_update(resource_group_name, vault_name, vault)
Expand Down Expand Up @@ -310,7 +319,18 @@ def _get_vault_security_settings(immutability_state, existing_vault=None):
security_settings.soft_delete_settings = soft_delete_settings

if immutability_state is not None:
security_settings.immutability_settings = ImmutabilitySettings(state=immutability_state)
# When immutability state is Unlocked or Locked (enabled states), we need to provide
# a configuration with type. Default to "AsPerPolicy" as it doesn't require durationInDays.
# When state is Disabled, we don't need to provide configuration.
if immutability_state in ("Unlocked", "Locked"):
immutability_config = ImmutabilityConfiguration(type="AsPerPolicy")
security_settings.immutability_settings = ImmutabilitySettings(
state=immutability_state,
configuration=immutability_config
)
else:
# For Disabled state, only set the state without configuration
security_settings.immutability_settings = ImmutabilitySettings(state=immutability_state)

return security_settings

Expand All @@ -324,6 +344,20 @@ def _get_vault_restore_settings(cross_subscription_restore_state):
return restore_settings


def _get_vault_cost_management_settings(cost_management_granularity, existing_vault=None):
# Update scenario
if existing_vault is not None:
cost_management_settings = existing_vault.properties.cost_management_settings
else:
# Create scenario
cost_management_settings = CostManagementSettings()
cost_management_settings.granularity_level = "VaultLevel"

if cost_management_granularity is not None:
cost_management_settings.granularity_level = cost_management_granularity
return cost_management_settings


def _get_vault_public_network_access(public_network_access):
return cust_help.transform_enable_parameters(public_network_access)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,108 @@
import os
from datetime import datetime, timedelta

from azure.cli.testsdk import CliTestError, ResourceGroupPreparer
from azure.cli.testsdk import CliTestError
from azure.cli.testsdk.preparers import AbstractPreparer, SingleValueReplacer, KeyVaultPreparer
from azure.cli.testsdk.preparers import ResourceGroupPreparer as _CliResourceGroupPreparer
from azure.cli.testsdk.preparers import StorageAccountPreparer as _CliStorageAccountPreparer
from azure.cli.testsdk.utilities import StorageAccountKeyReplacer
from azure.cli.testsdk.base import execute
# pylint: disable=line-too-long

from knack.log import get_logger
logger = get_logger(__name__)


# Temporary Resource Group Preparer for testing while we update the RecoveryServices SDK to deal with new Soft Delete rules
class RGPreparer(AbstractPreparer, SingleValueReplacer):
def __init__(self, name_prefix='clitest.rg',
parameter_name='resource_group',
parameter_name_for_location='resource_group_location', location='westus',
dev_setting_name='AZURE_CLI_TEST_DEV_RESOURCE_GROUP_NAME',
dev_setting_location='AZURE_CLI_TEST_DEV_RESOURCE_GROUP_LOCATION',
random_name_length=75, key='rg', subscription=None, additional_tags=None):
if ' ' in name_prefix:
raise CliTestError('Error: Space character in resource group name prefix \'%s\'' % name_prefix)
super().__init__(name_prefix, random_name_length)
from azure.cli.core.mock import DummyCli
self.cli_ctx = DummyCli()
self.location = location
self.subscription = subscription
self.parameter_name = parameter_name
self.parameter_name_for_location = parameter_name_for_location
self.key = key
self.additional_tags = additional_tags

self.dev_setting_name = os.environ.get(dev_setting_name, None)
self.dev_setting_location = os.environ.get(dev_setting_location, location)
# Tags required by the "BCDR_StorageAccount_RequiredTags" Azure Policy (deny effect,
# assigned at the management-group scope). A storage account that allows shared-key
# access is rejected unless it carries all of these tags. AFS backup needs shared-key
# / local auth enabled on the account, so DisableLocalAuth must be present and 'false'.
BACKUP_SA_REQUIRED_TAGS = {
'DisableLocalAuth': 'false',
'Reason': 'CLITest',
'ETA': '12-2099',
'Owner': 'clitest',
}


class StorageAccountPreparer(_CliStorageAccountPreparer):
"""Storage account preparer for backup tests.

Creates the account with the tags required by the BCDR storage-account tag
policy (a management-group deny policy rejects an account that allows
shared-key access unless it carries these tags) and with a supported account
kind (StorageV2 - the legacy GPv1 'Storage' kind is no longer allowed for new
accounts). Drop-in replacement for the testsdk preparer.
"""
def __init__(self, *args, kind='StorageV2', tags=None, **kwargs):
super().__init__(*args, kind=kind, **kwargs)
self.tags = dict(BACKUP_SA_REQUIRED_TAGS) if tags is None else tags

def create_resource(self, name, **kwargs):
cmd = 'az group create --location {} --name {}'.format(self.location, name)
execute(self.cli_ctx, cmd)
return {self.parameter_name: name, self.parameter_name_for_location: self.location}

group = self._get_resource_group(**kwargs)
if not self.dev_setting_name:
template = 'az storage account create -n {} -g {} -l {} --sku {} --kind {} --https-only'
template += ' --allow-blob-public-access {}'.format('true' if self.allow_blob_public_access else 'false')
if self.allow_shared_key_access is not None:
template += ' --allow-shared-key-access {}'.format('true' if self.allow_shared_key_access else 'false')
if self.hns:
template += ' --hns'
command = template.format(name, group, self.location, self.sku, self.kind)
if self.tags:
command += ' --tags ' + ' '.join('{}={}'.format(k, v) for k, v in self.tags.items())
self.live_only_execute(self.cli_ctx, command)
else:
name = self.dev_setting_name
try:
account_key = self.live_only_execute(
self.cli_ctx,
'storage account keys list -n {} -g {} --query "[0].value" -otsv'.format(name, group)).output
except AttributeError: # live_only_execute returns None when playing back a recording
account_key = None
self.test_class_instance.kwargs[self.key] = name
return {self.parameter_name: name,
self.parameter_name + '_info': (name, account_key or StorageAccountKeyReplacer.KEY_REPLACEMENT)}


# The single custom resource group preparer for backup tests. Subclasses the CLI
# ResourceGroupPreparer and makes teardown resilient to the CanNotDelete lock that
# Azure Backup places on a protected AFS storage account: Backup releases the lock
# asynchronously, so `az group delete` can race it and fail with ScopeLocked, so we
# clear any locks in the group and retry the delete a bounded number of times.
class ResourceGroupPreparer(_CliResourceGroupPreparer):
_MAX_DELETE_ATTEMPTS = 6
_DELETE_RETRY_WAIT = 15

def remove_resource(self, name, **kwargs):
pass
if self.dev_setting_name:
return

import time
from azure.core.exceptions import HttpResponseError

sub = ' --subscription {}'.format(self.subscription) if self.subscription else ''
for attempt in range(self._MAX_DELETE_ATTEMPTS):
self._remove_locks(name, sub)
try:
self.live_only_execute(self.cli_ctx, 'az group delete --name {} --yes --no-wait{}'.format(name, sub))
return
except HttpResponseError as ex:
if 'ScopeLocked' not in str(ex) or attempt == self._MAX_DELETE_ATTEMPTS - 1:
raise
time.sleep(self._DELETE_RETRY_WAIT)

def _remove_locks(self, name, sub):
from azure.core.exceptions import HttpResponseError
try:
result = self.live_only_execute(self.cli_ctx, 'az lock list -g {}{} -o json'.format(name, sub))
locks = result.get_output_in_json() if result else []
except (HttpResponseError, AttributeError):
return
for lock in locks or []:
try:
self.live_only_execute(self.cli_ctx, 'az lock delete --ids {}'.format(lock['id']))
except (HttpResponseError, AttributeError):
pass


class VaultPreparer(AbstractPreparer, SingleValueReplacer): # pylint: disable=too-many-instance-attributes
Expand Down
Loading