Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 39 additions & 8 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,11 +319,21 @@ 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


def _get_vault_restore_settings(cross_subscription_restore_state):
restore_settings = None
if cross_subscription_restore_state is not None:
Expand All @@ -323,6 +342,18 @@ def _get_vault_restore_settings(cross_subscription_restore_state):
cross_subscription_restore_state=cust_help.transform_enable_parameters(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
Loading