diff --git a/src/azure-cli/azure/cli/command_modules/resource/_color.py b/src/azure-cli/azure/cli/command_modules/resource/_color.py index 7999530de7f..b14eb162525 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/_color.py +++ b/src/azure-cli/azure/cli/command_modules/resource/_color.py @@ -12,6 +12,7 @@ class Color(Enum): GREEN = "\033[38;5;77m" PURPLE = "\033[38;5;141m" BLUE = "\033[38;5;39m" + CYAN = "\033[38;5;51m" GRAY = "\033[38;5;246m" RED = "\033[38;5;203m" DARK_YELLOW = "\033[38;5;136m" @@ -26,11 +27,15 @@ def __init__(self, enable_color=True): self._enable_color = enable_color self._contents = [] self._colors = deque() + self._indents = [] def build(self): return "".join(self._contents) - def append(self, value, color=None): + def append(self, value, color=None, no_indent=False): + if not no_indent and self._should_indent(): + self._contents.append(''.join(self._indents)) + if color: self._push_color(color) @@ -41,14 +46,55 @@ def append(self, value, color=None): return self - def append_line(self, value="", color=None): - self.append(f"{str(value)}\n", color) - - return self + def append_line(self, value="", color=None, no_indent=False): + self.append(value, color, no_indent) + return self.append("\n", no_indent=True) def new_color_scope(self, color): return self.ColorScope(self, color) + def insert(self, index, value="", color=None, no_indent=False): + if color and self._enable_color: + self._contents.insert(index, str(Color.RESET)) + + self._contents.insert(index, str(value)) + + if color and self._enable_color: + self._contents.insert(index, str(color)) + + if not no_indent and self._should_indent(index, True): + self._contents.insert(index, ''.join(self._indents)) + + return self + + def insert_line(self, index, value="", color=None, no_indent=False): + self.insert(index, "\n", no_indent=no_indent) + return self.insert(index, value, color, no_indent) + + def get_current_index(self): + return len(self._contents) + + def push_indent(self, indent): + self._indents.append(indent) + + def pop_indent(self): + self._indents.pop() + + def ensure_num_new_lines(self, num_new_lines): + if len(self._contents) == 0: + self.append("\n" * num_new_lines) + return + + last_entry = self._contents[-1] + existing_newlines = len(last_entry) - len(last_entry.rstrip('\n')) + remaining_newlines = num_new_lines - existing_newlines + + if remaining_newlines > 0: + self._contents.append("\n" * remaining_newlines) + + def clear(self): + self._contents = [] + def _push_color(self, color): if not self._enable_color: return @@ -63,6 +109,10 @@ def _pop_color(self): self._colors.pop() self._contents.append(str(self._colors[-1] if self._colors else Color.RESET)) + def _should_indent(self, index=-1, is_insert=False): + return len(self._indents) > 0 and ( + not self._contents or self._contents[max(index - 1, 0) if is_insert else index].endswith("\n")) + # pylint: disable=protected-access class ColorScope: def __init__(self, color_string_builder, color): diff --git a/src/azure-cli/azure/cli/command_modules/resource/_help.py b/src/azure-cli/azure/cli/command_modules/resource/_help.py index 524c4a48210..37db5459af8 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/_help.py +++ b/src/azure-cli/azure/cli/command_modules/resource/_help.py @@ -2460,6 +2460,12 @@ long-summary: Deployment stacks are defined in ARM as the type Microsoft.Resources/deploymentStacks. """ +helps['stack-whatif'] = """ +type: group +short-summary: A deployment stack What-If is a preview of an operation to be performed on a new or existing deployment stack. +long-summary: Deployment stacks What-Ifs are defined in ARM as the type Microsoft.Resources/deploymentStacksWhatIfResults. +""" + helps['stack mg'] = """ type: group short-summary: Manage Deployment Stacks at management group. @@ -2547,9 +2553,66 @@ text: az stack mg delete --id /providers/Microsoft.Management/managementGroups/myMg/providers/Microsoft.Resources/deploymentStacks/StackName --management-group-id myMg --action-on-unmanage deleteAll """ +helps['stack-whatif mg'] = """ +type: group +short-summary: Manage Deployment stacks what-if results at management group scope. +""" + +helps['stack-whatif mg create'] = """ +type: command +short-summary: Create a deployment stack what-if result at management group scope. +examples: + - name: Perform a what-if on a deployment stack using template file and detach all unmanaged resources. + text: az stack-whatif mg create --name StackName --management-group-id myMg --template-file simpleTemplate.json --parameters simpleTemplateParams.json --location westus2 --description description --deny-settings-mode None --action-on-unmanage detachAll --ri P5D --stack /providers/Microsoft.Management/myMg/providers/Microsoft.Resources/deploymentStacks/myMgStack + - name: Perform a what-if on a deployment stack with template spec. + text: az stack-whatif mg create --name StackName --management-group-id myMg --template-spec TemplateSpecResourceIDWithVersion --location westus2 --description description --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /providers/Microsoft.Management/myMg/providers/Microsoft.Resources/deploymentStacks/myMgStack + - name: Perform a what-if on a deployment stack using bicep file, delete all resources on unmanage, and remove color from the output. + text: az stack-whatif mg create --name StackName --management-group-id myMg --no-color --action-on-unmanage deleteAll --template-file simple.bicep --location westus2 --description description --deny-settings-mode None --ri P5D --stack /providers/Microsoft.Management/myMg/providers/Microsoft.Resources/deploymentStacks/myMgStack + - name: Perform a what-if on a deployment stack using parameters from key/value pairs. + text: az stack-whatif mg create --name StackName --management-group-id myMg --template-file simpleTemplate.json --location westus --description description --parameters simpleTemplateParams.json value1=foo value2=bar --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /providers/Microsoft.Management/myMg/providers/Microsoft.Resources/deploymentStacks/myMgStack + - name: Perform a what-if on a deployment stack from a local template, using a parameter file, a remote parameter file, and selectively overriding key/value pairs. + text: az stack-whatif mg create --name StackName --management-group-id myMg --template-file azuredeploy.json --parameters @params.json --parameters https://mysite/params.json --parameters MyValue=This MyArray=@array.json --location westus --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /providers/Microsoft.Management/myMg/providers/Microsoft.Resources/deploymentStacks/myMgStack + - name: Perform a what-if on a deployment stack from a local template, using deny settings. + text: az stack-whatif mg create --name StackName --management-group-id myMg --template-file azuredeploy.json --deny-settings-mode denyDelete --deny-settings-excluded-actions Microsoft.Compute/virtualMachines/write --deny-settings-excluded-principals "test1 test2" --location westus --action-on-unmanage deleteResources --ri P5D --stack /providers/Microsoft.Management/myMg/providers/Microsoft.Resources/deploymentStacks/myMgStack + - name: Perform a what-if on a deployment stack from a local template, apply deny settings to child scope. + text: az stack-whatif mg create --name StackName --management-group-id myMg --template-file azuredeploy.json --deny-settings-mode denyDelete --deny-settings-excluded-actions Microsoft.Compute/virtualMachines/write --deny-settings-apply-to-child-scopes --location westus --action-on-unmanage deleteResources --ri P5D --stack /providers/Microsoft.Management/myMg/providers/Microsoft.Resources/deploymentStacks/myMgStack +""" + +helps['stack-whatif mg list'] = """ +type: command +short-summary: List all deployment stacks what-if results in a management group. +examples: + - name: List all stack what-if results in management group. + text: az stack-whatif mg list --management-group-id myMg +""" + +helps['stack-whatif mg show'] = """ +type: command +short-summary: Get a deployment stack what-if result from management group scope. +examples: + - name: Get a stack what-if result by name. + text: az stack-whatif mg show --name ResultName --management-group-id myMg + - name: Get a stack what-if result by name without color. + text: az stack-whatif mg show --name ResultName --management-group-id myMg --no-color + - name: Get JSON for a stack what-if result by name. + text: az stack-whatif mg show --name ResultName --management-group-id myMg --no-pretty-print + - name: Get a stack what-if result by resource id. + text: az stack-whatif mg show --id /providers/Microsoft.Management/managementGroups/myMg/providers/Microsoft.Resources/deploymentStacksWhatIfResults/ResultName --management-group-id myMg +""" + +helps['stack-whatif mg delete'] = """ +type: command +short-summary: Delete a deployment stack what-if result from management group scope. +examples: + - name: Delete a stack what-if result by name. + text: az stack-whatif mg delete --name ResultName --management-group-id myMg + - name: Delete a stack what-if result by resource id. + text: az stack-whatif mg delete --id /providers/Microsoft.Management/managementGroups/myMg/providers/Microsoft.Resources/deploymentStacksWhatIfResults/ResultName --management-group-id myMg +""" + helps['stack sub'] = """ type: group -short-summary: Manage Deployment Stacks at subscription. +short-summary: Manage Deployment stacks at subscription scope. """ helps['stack sub create'] = """ @@ -2642,9 +2705,72 @@ text: az stack sub delete --id /subscriptions/111111111111/providers/Microsoft.Resources/deploymentStacks/StackName --action-on-unmanage detachAll """ +helps['stack-whatif sub'] = """ +type: group +short-summary: Manage Deployment stacks what-if results at subscription scope. +""" + +helps['stack-whatif sub create'] = """ +type: command +short-summary: Create a deployment stack what-if result at subscription scope. +examples: + - name: Perform a what-if on a deployment stack using template file and detach all resources on unmanage. + text: az stack-whatif sub create --name StackName --template-file simpleTemplate.json --location westus2 --description description --deny-settings-mode None --action-on-unmanage detachAll --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack with parameter file, delete resources on unmanage, and remove color from the output. + text: az stack-whatif sub create --name StackName --action-on-unmanage deleteResources --no-color --template-file simpleTemplate.json --parameters simpleTemplateParams.json --location westus2 --description description --deny-settings-mode None --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack with template spec. + text: az stack-whatif sub create --name StackName --template-spec TemplateSpecResourceIDWithVersion --location westus2 --description description --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack using bicep file and delete all resources on unmanage. + text: az stack-whatif sub create --name StackName --action-on-unmanage deleteAll --template-file simple.bicep --location westus2 --description description --deny-settings-mode None --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack at a different subscription. + text: az stack-whatif sub create --name StackName --template-file simpleTemplate.json --location westus2 --description description --subscription subscriptionId --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack and deploy at the resource group scope. + text: az stack-whatif sub create --name StackName --template-file simpleTemplate.json --location westus --deployment-resource-group ResourceGroup --description description --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack using parameters from key/value pairs. + text: az stack-whatif sub create --name StackName --template-file simpleTemplate.json --location westus --description description --parameters simpleTemplateParams.json value1=foo value2=bar --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack from a local template, using a parameter file, a remote parameter file, and selectively overriding key/value pairs. + text: az stack-whatif sub create --name StackName --template-file azuredeploy.json --parameters @params.json --parameters https://mysite/params.json --parameters MyValue=This MyArray=@array.json --location westus --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack from a local template, using deny settings. + text: az stack-whatif sub create --name StackName --template-file azuredeploy.json --deny-settings-mode denyDelete --deny-settings-excluded-actions Microsoft.Compute/virtualMachines/write --deny-settings-excluded-principals "test1 test2" --location westus --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack from a local template, apply deny settings to child scopes. + text: az stack-whatif sub create --name StackName --template-file azuredeploy.json --deny-settings-mode denyDelete --deny-settings-excluded-actions Microsoft.Compute/virtualMachines/write --deny-settings-apply-to-child-scopes --location westus --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacks/mySubStack +""" + +helps['stack-whatif sub list'] = """ +type: command +short-summary: List all deployment stack what-if results in a subscription. +examples: + - name: List all stack what-if results the current subscription. + text: az stack-whatif sub list +""" + +helps['stack-whatif sub show'] = """ +type: command +short-summary: Get a deployment stack what-if result from subscription scope. +examples: + - name: Get a stack what-if result by name. + text: az stack-whatif sub show --name ResultName + - name: Get a stack what-if result by name without color. + text: az stack-whatif sub show --name ResultName --no-color + - name: Get JSON for a stack what-if result by name. + text: az stack-whatif sub show --name ResultName --no-pretty-print + - name: Get a stack what-if result by resource id. + text: az stack-whatif sub show --id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacksWhatIfResults/ResultName +""" + +helps['stack-whatif sub delete'] = """ +type: command +short-summary: Delete a deployment stack what-if result from subscription scope. +examples: + - name: Delete a stack what-if result by name. + text: az stack-whatif sub delete --name StackName + - name: Delete a stack what-if result by resource id. + text: az stack-whatif sub delete --id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Resources/deploymentStacksWhatIfResults/ResultName +""" + helps['stack group'] = """ type: group -short-summary: Manage Deployment Stacks at resource group. +short-summary: Manage Deployment stacks at resource group scope. """ helps['stack group create'] = """ @@ -2733,6 +2859,67 @@ text: az stack group delete --id /subscriptions/111111111111/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/StackName --action-on-unmanage detachAll """ +helps['stack-whatif group'] = """ +type: group +short-summary: Manage Deployment stacks what-if results at resource group scope. +""" + +helps['stack-whatif group create'] = """ +type: command +short-summary: Create a deployment stack what-if result at resource group scope. +examples: + - name: Perform a what-if on a deployment stack using template file and delete resources on unmanage. + text: az stack-whatif group create --name ResultName --resource-group ResourceGroup --action-on-unmanage deleteResources --template-file simpleTemplate.json --description description --deny-settings-mode None --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack with parameter file, detach all resources on unmanage, and remove color from the output. + text: az stack-whatif group create --name ResultName --resource-group ResourceGroup --no-color --action-on-unmanage detachAll --template-file simpleTemplate.json --parameters simpleTemplateParams.json --description description --deny-settings-mode None --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack with template spec and delete all resources on unmanage. + text: az stack-whatif group create --name ResultName --resource-group ResourceGroup --action-on-unmanage deleteAll --template-spec TemplateSpecResourceIDWithVersion --description description --deny-settings-mode None --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack using bicep file. + text: az stack-whatif group create --name ResultName --resource-group ResourceGroup --template-file simple.bicep --description description --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack at a different subscription. + text: az stack-whatif group create --name ResultName --resource-group ResourceGroup --template-file simpleTemplate.json --description description --subscription subscriptionId --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack using parameters from key/value pairs. + text: az stack-whatif group create --name ResultName --template-file simpleTemplate.json --resource-group ResourceGroup --description description --parameters simpleTemplateParams.json value1=foo value2=bar --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack from a local template, using a parameter file, a remote parameter file, and selectively overriding key/value pairs. + text: az stack-whatif group create --name ResultName --template-file azuredeploy.json --parameters @params.json --parameters https://mysite/params.json --parameters MyValue=This MyArray=@array.json --resource-group ResourceGroup --deny-settings-mode None --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack from a local template, using deny settings. + text: az stack-whatif group create --name ResultName --resource-group ResourceGroup --template-file azuredeploy.json --deny-settings-mode denyDelete --deny-settings-excluded-actions Microsoft.Compute/virtualMachines/write --deny-settings-excluded-principals "test1 test2" --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack + - name: Perform a what-if on a deployment stack from a local template, apply deny setting to child scopes. + text: az stack-whatif group create --name ResultName --resource-group ResourceGroup --template-file azuredeploy.json --deny-settings-mode denyDelete --deny-settings-excluded-actions Microsoft.Compute/virtualMachines/write --deny-settings-apply-to-child-scopes --action-on-unmanage deleteResources --ri P5D --stack /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacks/mySubStack +""" + +helps['stack-whatif group list'] = """ +type: command +short-summary: List all deployment stack what-if results in a resource group. +examples: + - name: List all stack what-if results in a resource group. + text: az stack-whatif group list --resource-group ResourceGroup +""" + +helps['stack-whatif group show'] = """ +type: command +short-summary: Get a deployment stack what-if result from resource group scope. +examples: + - name: Get a stack what-if result by name. + text: az stack-whatif group show --name ResultName --resource-group ResourceGroup + - name: Get a stack what-if result by name without color. + text: az stack-whatif group show --name ResultName --resource-group ResourceGroup --no-color + - name: Get JSON for a stack what-if result by name. + text: az stack-whatif group show --name ResultName --resource-group ResourceGroup --no-pretty-print + - name: Get a stack what-if result by resource id. + text: az stack-whatif group show --id /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacksWhatIfResults/ResultName +""" + +helps['stack-whatif group delete'] = """ +type: command +short-summary: Delete a deployment stack what-if result from resource group scope. +examples: + - name: Delete stack what-if result by name. + text: az stack-whatif group delete --name ResultName --resource-group ResourceGroup + - name: Delete stack what-if result by resource id. + text: az stack-whatif group delete --id /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ResourceGroup/providers/Microsoft.Resources/deploymentStacksWhatIfResults/ResultName +""" + helps['bicep generate-params'] = """ type: command short-summary: Generate parameters file for a Bicep file. diff --git a/src/azure-cli/azure/cli/command_modules/resource/_params.py b/src/azure-cli/azure/cli/command_modules/resource/_params.py index 5540de0295b..d6d96ca4684 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/_params.py +++ b/src/azure-cli/azure/cli/command_modules/resource/_params.py @@ -72,6 +72,8 @@ def load_arguments(self, _): min_api='2019-07-01') deployment_what_if_no_pretty_print_type = CLIArgumentType(options_list=['--no-pretty-print'], action='store_true', help='Disable pretty-print for What-If results. When set, the output format type will be used.') + deployment_what_if_no_color_type = CLIArgumentType(options_list=['--no-color'], action='store_true', + help='Disable color in pretty-printed what-if results.') deployment_what_if_confirmation_type = CLIArgumentType(options_list=['--confirm-with-what-if', '-c'], action='store_true', help='Instruct the command to run deployment What-If before executing the deployment. It then prompts you to acknowledge resource changes before it continues.', min_api='2019-07-01') @@ -618,29 +620,32 @@ def load_arguments(self, _): with self.argument_context('ts list') as c: c.argument('resource_group', arg_type=resource_group_name_type) - with self.argument_context('stack mg') as c: - c.argument('management_group_id', arg_type=management_group_id_type, help='The management group id to create stack at.') - - # TODO(kylealbert): add "stack-whatif" - for resource_type in ['stack']: + for resource_type in ['stack', 'stack-whatif']: for scope in ['group', 'sub', 'mg']: for action in ['create', 'validate', 'delete', 'show', 'list', 'export']: if resource_type == 'stack-whatif' and (action == 'validate' or action == 'export'): continue with self.argument_context(f'{resource_type} {scope} {action}') as c: + entity_type = "deployment stack what-if result" if resource_type == 'stack-whatif' else "deployment stack" + + if scope == 'group': + c.argument('resource_group', arg_type=resource_group_name_type, help=f'The resource group where the {entity_type} exists.') + elif scope == 'mg': + c.argument('management_group_id', arg_type=management_group_id_type, help=f'The management group ID to create a {entity_type} in.') + if action == 'create' or action == 'validate': - c.argument('name', arg_type=stacks_name_type) + c.argument('name', arg_type=stacks_name_type, help=f'The name of the {entity_type}.') if scope == 'group': - c.argument('resource_group', arg_type=resource_group_name_type, help='The resource group where the deployment stack will be created.') + c.argument('resource_group', arg_type=resource_group_name_type, help=f'The resource group where the {entity_type} will be created.') elif scope == 'sub': c.argument('deployment_resource_group', arg_type=stacks_stack_deployment_resource_group) elif scope == 'mg': c.argument('deployment_subscription', arg_type=stacks_stack_deployment_subscription) if scope != 'group': - c.argument('location', arg_type=get_location_type(self.cli_ctx), help='The location to store deployment stack.') + c.argument('location', arg_type=get_location_type(self.cli_ctx), help=f'The location to store the {entity_type}.') c.argument('template_file', arg_type=deployment_template_file_type) c.argument('template_spec', arg_type=deployment_template_spec_type) @@ -662,30 +667,30 @@ def load_arguments(self, _): elif resource_type == 'stack-whatif': c.argument('stack_id', arg_type=stacks_whatif_stack_id_type) c.argument('retention_interval', arg_type=stacks_whatif_retention_interval_type) + c.argument('no_pretty_print', arg_type=deployment_what_if_no_pretty_print_type) + c.argument('no_color', arg_type=deployment_what_if_no_color_type) if action == 'create' and resource_type == 'stack': - c.argument('yes', help='Do not prompt for confirmation') + c.argument('yes', help='Do not prompt for confirmation.') elif action == 'delete': - c.argument('name', options_list=['--name', '-n'], arg_type=stacks_stack_name_type) - c.argument('id', arg_type=stacks_stack_type) + c.argument('name', options_list=['--name', '-n'], arg_type=stacks_stack_name_type, help=f'The name of the {entity_type}.') + c.argument('id', arg_type=stacks_stack_type, help=f'The {entity_type} resource ID.') c.argument('subscription', arg_type=subscription_type) - c.argument('action_on_unmanage', arg_type=stacks_action_on_unmanage_type) - c.argument('resources_without_delete_support', arg_type=stacks_resources_without_delete_support_type) - c.argument('bypass_stack_out_of_sync_error', arg_type=stacks_bypass_stack_out_of_sync_error_type) - c.argument('yes', help='Do not prompt for confirmation') - if scope == 'group': - c.argument('resource_group', arg_type=resource_group_name_type, help='The resource group where the deployment stack exists') + c.argument('yes', help='Do not prompt for confirmation.') + if resource_type == 'stack': + c.argument('action_on_unmanage', arg_type=stacks_action_on_unmanage_type) + c.argument('resources_without_delete_support', arg_type=stacks_resources_without_delete_support_type) + c.argument('bypass_stack_out_of_sync_error', arg_type=stacks_bypass_stack_out_of_sync_error_type) elif action == 'show' or action == 'export': - c.argument('name', options_list=['--name', '-n'], arg_type=stacks_stack_name_type) - c.argument('id', arg_type=stacks_stack_type) + c.argument('name', options_list=['--name', '-n'], arg_type=stacks_stack_name_type, help=f'The name of the {entity_type}.') + c.argument('id', arg_type=stacks_stack_type, help=f'The {entity_type} resource ID.') c.argument('subscription', arg_type=subscription_type) - if scope == 'group': - c.argument('resource_group', arg_type=resource_group_name_type, help='The resource group where the deployment stack exists') + if resource_type == 'stack-whatif': + c.argument('no_pretty_print', arg_type=deployment_what_if_no_pretty_print_type) + c.argument('no_color', arg_type=deployment_what_if_no_color_type) elif action == 'list': if scope == 'sub': continue # only uses global arguments c.argument('subscription', arg_type=subscription_type) - if scope == 'group': - c.argument('resource_group', arg_type=resource_group_name_type, help='The resource group where the deployment stack exists') with self.argument_context('bicep build') as c: c.argument('file', arg_type=bicep_file_type, help="The path to the Bicep file to build in the file system.") diff --git a/src/azure-cli/azure/cli/command_modules/resource/_stacks_formatters.py b/src/azure-cli/azure/cli/command_modules/resource/_stacks_formatters.py new file mode 100644 index 00000000000..cdc1f642a12 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/resource/_stacks_formatters.py @@ -0,0 +1,551 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import typing as t + +from requests.structures import CaseInsensitiveDict + +import azure.mgmt.resource.deploymentstacks.models as StackModels +# from itertools import groupby + +from ._color import Color, ColoredStringBuilder +from ._utils import str_lower_eq + +ALL_WHAT_IF_TOP_LEVEL_CHANGE_TYPES = [ + StackModels.DeploymentStacksWhatIfChangeType.CREATE, + StackModels.DeploymentStacksWhatIfChangeType.UNSUPPORTED, + StackModels.DeploymentStacksWhatIfChangeType.MODIFY, + StackModels.DeploymentStacksWhatIfChangeType.DELETE, + StackModels.DeploymentStacksWhatIfChangeType.NO_CHANGE, + StackModels.DeploymentStacksWhatIfChangeType.DETACH +] + + +class DeploymentStacksWhatIfResultFormatter: # pylint: disable=too-few-public-methods + INDENT_SIZE = 2 + + # NOTE(kylealbert): Some of these overlap with property change types + CHANGE_TYPE_SYMBOLS = CaseInsensitiveDict( + { + StackModels.DeploymentStacksWhatIfPropertyChangeType.ARRAY: '~', + StackModels.DeploymentStacksWhatIfChangeType.CREATE: '+', + StackModels.DeploymentStacksWhatIfChangeType.DELETE: '-', + StackModels.DeploymentStacksWhatIfChangeType.DETACH: 'v', + StackModels.DeploymentStacksWhatIfChangeType.MODIFY: '~', + StackModels.DeploymentStacksWhatIfChangeType.NO_CHANGE: '=', + StackModels.DeploymentStacksWhatIfPropertyChangeType.NO_EFFECT: '=', + StackModels.DeploymentStacksWhatIfChangeType.UNSUPPORTED: '!', + }) + + CHANGE_TYPE_COLORS = CaseInsensitiveDict( + { + StackModels.DeploymentStacksWhatIfPropertyChangeType.ARRAY: Color.PURPLE, + StackModels.DeploymentStacksWhatIfChangeType.CREATE: Color.GREEN, + StackModels.DeploymentStacksWhatIfChangeType.DELETE: Color.RED, + StackModels.DeploymentStacksWhatIfChangeType.DETACH: Color.BLUE, + StackModels.DeploymentStacksWhatIfChangeType.MODIFY: Color.PURPLE + }) + + CHANGE_CERTAINTY_PRIORITIES = CaseInsensitiveDict( + { + StackModels.DeploymentStacksWhatIfChangeCertainty.DEFINITE: 0, + StackModels.DeploymentStacksWhatIfChangeCertainty.POTENTIAL: 1 + }) + + DIAGNOSTIC_LEVEL_PRIORITIES = CaseInsensitiveDict( + { + StackModels.DeploymentStacksDiagnosticLevel.INFO: 1, + StackModels.DeploymentStacksDiagnosticLevel.WARNING: 2, + StackModels.DeploymentStacksDiagnosticLevel.ERROR: 3, + }) + + DIAGNOSTIC_COLORS = CaseInsensitiveDict( + { + StackModels.DeploymentStacksDiagnosticLevel.WARNING: Color.DARK_YELLOW, + StackModels.DeploymentStacksDiagnosticLevel.ERROR: Color.RED, + }) + + def __init__(self, enable_color=True): + self.builder: ColoredStringBuilder = ColoredStringBuilder(enable_color) + self.what_if_result: t.Optional[StackModels.DeploymentStacksWhatIfResult] = None + self.what_if_props: t.Optional[StackModels.DeploymentStacksWhatIfResultProperties] = None + self.what_if_changes: t.Optional[StackModels.DeploymentStacksWhatIfChange] = None + + def format(self, what_if_result: StackModels.DeploymentStacksWhatIfResult) -> str: + self.builder.clear() + + self.what_if_result = what_if_result + self.what_if_props = what_if_result.properties + self.what_if_changes = self.what_if_props.changes if self.what_if_props else None + + if self._format_change_type_legend(): + self._format_section_spacer() + if self._format_stack_changes(): + self._format_section_spacer() + if self._format_resource_changes_and_deletion_summary(): + self._format_section_spacer() + self._format_diagnostics() + + result = self.builder.build() + self.what_if_result = self.what_if_props = None + + return result + + def _format_section_spacer(self): + self.builder.ensure_num_new_lines(2) + + def _format_change_type_legend(self) -> bool: + change_type_max_length = 20 + + self.builder.append_line("Resource and property changes are indicated with these symbols:") + self._push_indent() + + for i, change_type in enumerate(ALL_WHAT_IF_TOP_LEVEL_CHANGE_TYPES): + change_type_label = change_type[0].upper() + change_type[1:] + symbol, color = self._get_change_type_formatting(change_type) + + self.builder.append(symbol, color).append(" ").append(change_type_label) + + if i % 2 == 0: + remaining_indent = max(1, change_type_max_length - len(change_type_label)) + self.builder.append(" " * remaining_indent) + elif i < len(ALL_WHAT_IF_TOP_LEVEL_CHANGE_TYPES) - 1: + self.builder.append_line() + + self._pop_indent() + + return True + + def _format_stack_changes(self) -> bool: + if not self.what_if_changes: + return False + + printed = False + title_index = self.builder.get_current_index() + all_stack_changes = { + "DeploymentScope": self.what_if_changes.deployment_scope_change, + "DenySettings": self.what_if_changes.deny_settings_change + } + + for path, change in all_stack_changes.items(): + if self._format_change(change, path): + printed = True + + if printed: + self.builder.insert_line( + title_index, f"Changes to Stack {self.what_if_props.deployment_stack_resource_id}:", Color.DARK_YELLOW) + + return printed + + def _format_resource_changes_and_deletion_summary(self) -> bool: + if not self.what_if_changes or not self.what_if_changes.resource_changes: + return False + + printed = False + resource_changes_sorted = sorted( + self.what_if_changes.resource_changes, + key=lambda x: ( + 0 if x.id else 1, # sort Azure resources before extension resources + DeploymentStacksWhatIfResultFormatter.CHANGE_CERTAINTY_PRIORITIES.get( + x.change_certainty, 1) if x.id else 0, # Azure resources: then by certainty + x.id.lower() if x.id else "", # Azure resources: then by ID + # Extension resources: then by (ext name, ext version, config id) + x.extension.name if x.extension else "", + x.extension.version if x.extension else "", + (x.extension.config_id if x.extension else "") or "", + DeploymentStacksWhatIfResultFormatter.CHANGE_CERTAINTY_PRIORITIES.get( + x.change_certainty, 1) if not x.id else 0, # Extension resources: then by certainty + x.type if x.extension else "", # Extension resources: then by type + # Extension resources: then by identifiers + self._format_ext_resource_identifiers(x.identifiers) if x.identifiers else "" + )) + + if self._format_resource_changes(resource_changes_sorted): + printed = True + if self._format_resource_deletions_summary(resource_changes_sorted): + printed = True + + return printed + + def _format_resource_changes( + self, resource_changes_sorted: list[StackModels.DeploymentStacksWhatIfResourceChange] + ) -> bool: + if resource_changes_sorted is None or len(resource_changes_sorted) == 0: + return False + + # Print the definite resource changes, followed by the potential changes + last_group: t.Optional[str] = None + has_potential_changes = False + + self.builder.append_line("Changes to Managed Resources:", Color.DARK_YELLOW) + + for change in resource_changes_sorted: + # check if a new section should be started + group = self._format_resource_class_header(change) + + if group != last_group: + last_group = group + has_potential_changes = False + self._format_section_spacer() + self.builder.append_line(group) + + if not has_potential_changes and str_lower_eq( + change.change_certainty, StackModels.DeploymentStacksWhatIfChangeCertainty.POTENTIAL): + self.builder.append(">> ").append_line( + "Potential Resource Changes (Learn more at https://aka.ms/whatIfPotentialChanges)", + Color.PURPLE) + has_potential_changes = True + + self._format_resource_change(change) + + return True + + def _format_resource_change(self, resource_change: StackModels.DeploymentStacksWhatIfResourceChange) -> bool: + # print the resource heading line + self._format_resource_heading_line(resource_change) + + # print stack management related changes + self._push_indent() + all_resource_changes = { + "Management Status": resource_change.management_status_change, + "Deny Status": resource_change.deny_status_change, + } + + for path, change in all_resource_changes.items(): + self._format_change(change, path) + + # print resource property changes + self._format_resource_property_changes(resource_change.resource_configuration_changes) + self._pop_indent() + + return True + + def _format_resource_deletions_summary( + self, resource_changes_sorted: list[StackModels.DeploymentStacksWhatIfResourceChange] + ) -> bool: + # Summarize the deletions, if any + printed = False + delete_changes = list( + filter( + lambda x: str_lower_eq(x.change_type, StackModels.DeploymentStacksWhatIfChangeType.DELETE), + resource_changes_sorted)) + + if len(delete_changes) > 0: + self._format_section_spacer() + self.builder.append("Deleting - ", Color.RED) + self.builder.append_line(f"Resources Marked for Deletion {len(delete_changes)} total:") + printed = True + + last_group: t.Optional[str] = None + has_potential_deletions = False + + for i, delete_change in enumerate(delete_changes): + group = self._format_resource_class_header(delete_change) + + if group != last_group: + self._format_section_spacer() + self.builder.append_line(group) + last_group = group + has_potential_deletions = False + + if not has_potential_deletions and str_lower_eq( + delete_change.change_certainty, StackModels.DeploymentStacksWhatIfChangeCertainty.POTENTIAL): + self.builder.append(">> ").append_line( + f"Potential Deletions {self._get_num_potential_resource_changes(delete_changes, i)} total" + " (Learn more at https://aka.ms/whatIfPotentialChanges)", + Color.RED) + has_potential_deletions = True + + self._format_resource_heading_line(delete_change) + + return printed + + def _format_resource_heading_line(self, resource_change: StackModels.DeploymentStacksWhatIfResourceChange): + symbol, color = self._get_change_type_formatting(resource_change.change_type) + + is_potential_change = str_lower_eq( + resource_change.change_certainty, StackModels.DeploymentStacksWhatIfChangeCertainty.POTENTIAL) + + # print the change type and resource ID + if is_potential_change: + self.builder.append("?", Color.CYAN) + self.builder.append(f"{symbol} ", color) + if is_potential_change: + self.builder.append("[Potential] ", Color.CYAN) + + api_version_suffix = f" [{resource_change.api_version}]" if resource_change.api_version else "" + resource_id = resource_change.id if resource_change.id else\ + f"{resource_change.type} {self._format_ext_resource_identifiers(resource_change.identifiers)}" + self.builder.append_line(f"{resource_id}{api_version_suffix}", color) + + def _format_resource_property_changes( + self, property_changes: t.Optional[StackModels.DeploymentStacksChangeDeltaRecord] + ) -> bool: + if not property_changes or not property_changes.delta: + return False + + printed = False + + for property_change in property_changes.delta: + if self._format_change(property_change): + printed = True + + return printed + + def _format_diagnostics(self) -> bool: + if not self.what_if_props or not self.what_if_props.diagnostics or len(self.what_if_props.diagnostics) == 0: + return False + + diagnostics_sorted = sorted( + self.what_if_props.diagnostics, + key=lambda x: ( + DeploymentStacksWhatIfResultFormatter.DIAGNOSTIC_LEVEL_PRIORITIES.get(x.level, 0), + x.code or "")) + + self.builder.append_line(f"Diagnostics ({len(diagnostics_sorted)}):") + + for diagnostic in diagnostics_sorted: + self._format_diagnostic(diagnostic) + + return True + + def _format_diagnostic(self, diagnostic: StackModels.DeploymentStacksDiagnostic): + self.builder.append_line( + f"{diagnostic.level.upper()}: [{diagnostic.code}] {diagnostic.message}", + DeploymentStacksWhatIfResultFormatter.DIAGNOSTIC_COLORS.get(diagnostic.level, None)) + + def _format_change( + self, + change: t.Optional[t.Union[ + StackModels.DeploymentStacksChangeBase, + StackModels.DeploymentStacksChangeDeltaRecord, + StackModels.DeploymentStacksWhatIfPropertyChange]], + parent_path: t.Optional[str] = None, + is_array_item: bool = False + ) -> bool: + if not change: + return False + + value_type = self._get_value_type_from_change(change) + + if value_type is str or value_type is bool or value_type is int or value_type is float: + if self._format_primitive_change(change, parent_path, is_array_item): + return True + elif value_type is list: + if self._format_array_changes(change, parent_path): + return True + elif value_type is dict: + if self._format_object_change(change, parent_path): + return True + + return False + + def _format_object_change( + self, + object_change: t.Optional[t.Union[ + StackModels.DeploymentStacksChangeDeltaRecord, StackModels.DeploymentStacksWhatIfPropertyChange]], + parent_path: t.Optional[str] = None + ) -> bool: + if not object_change: + return False + + children = object_change.delta if hasattr(object_change, "delta") else ( + object_change.children if hasattr(object_change, "children") else None) + + if not children or len(children) == 0: + return False + + printed = False + + for child in children: + if self._format_change(child, parent_path): + printed = True + + return printed + + def _format_array_changes( + self, array_change: StackModels.DeploymentStacksWhatIfPropertyChange, parent_path: t.Optional[str] = None + ) -> bool: + if not str_lower_eq(array_change.change_type, StackModels.DeploymentStacksWhatIfPropertyChangeType.ARRAY): + return False + + children = array_change.children + + if not children or len(children) == 0: + return False + + property_path = self._get_change_path(array_change, parent_path) + symbol, color = self._get_change_type_formatting(StackModels.DeploymentStacksWhatIfPropertyChangeType.MODIFY) + + self.builder.append_line(f"{symbol} {property_path}: ", color) + self._push_indent() + + print_array_indices = all(c.path for c in children) + sorted_children = sorted( + children, + key=lambda x: int(x.path)) if print_array_indices else children + + for i, item_change in enumerate(sorted_children): + if print_array_indices: + child_symbol, child_color = self._get_change_type_formatting(item_change.change_type) + self.builder.append(child_symbol, child_color).append_line(f" {item_change.path}:") + self._push_indent() + + if self._format_change(item_change, is_array_item=True) and i < len(array_change.children) - 1: + self.builder.ensure_num_new_lines(1) + + if print_array_indices: + self._pop_indent() + + self._pop_indent() + + return True + + def _format_primitive_change( + self, + primitive_change: t.Optional[ + t.Union[StackModels.DeploymentStacksChangeBase, StackModels.DeploymentStacksWhatIfPropertyChange]], + parent_path: t.Optional[str] = None, + is_array_item: bool = False + ) -> bool: + if not primitive_change: + return False + + change_type = primitive_change.change_type if hasattr(primitive_change, "change_type") else None + change_type = (change_type or (StackModels.DeploymentStacksWhatIfPropertyChangeType.NO_EFFECT + if primitive_change.before == primitive_change.after + else StackModels.DeploymentStacksWhatIfPropertyChangeType.MODIFY)) + + property_path = self._get_change_path(primitive_change, parent_path) + symbol, color = self._get_change_type_formatting(change_type) + + self.builder.append(f"{symbol} " if is_array_item else f"{symbol} {property_path}: ", color) + if str_lower_eq(change_type, StackModels.DeploymentStacksWhatIfPropertyChangeType.MODIFY): + self.builder.append_line( + f"{self._format_primitive_value(primitive_change.before)}" + f" => {self._format_primitive_value(primitive_change.after)}") + else: + value = primitive_change.before if str_lower_eq( + change_type, StackModels.DeploymentStacksWhatIfPropertyChangeType.DELETE) else primitive_change.after + self.builder.append_line( + self._format_primitive_value(value), color if is_array_item else None) + + return True + + def _push_indent(self, indent_size=INDENT_SIZE): + self.builder.push_indent(" " * indent_size) + + def _pop_indent(self): + self.builder.pop_indent() + + @staticmethod + def _format_primitive_value(value: t.Optional[t.Union[str, bool, int, float]]): + if value is None: + return "null" + return f'"{value}"' if isinstance(value, str) else str(value) + + @staticmethod + def _get_change_type_formatting( + change_type: t.Union[ + StackModels.DeploymentStacksWhatIfChangeType, StackModels.DeploymentStacksWhatIfPropertyChangeType, str] + ) -> t.Tuple[t.Optional[str], t.Optional[Color]]: + if change_type is None: + return None, None + + symbol = DeploymentStacksWhatIfResultFormatter.CHANGE_TYPE_SYMBOLS.get(change_type, None) + color = DeploymentStacksWhatIfResultFormatter.CHANGE_TYPE_COLORS.get(change_type, None) + + return symbol, color + + @staticmethod + def _get_change_path(change, parent_path: t.Optional[str] = None) -> str: + if hasattr(change, "path"): + return '.'.join([parent_path, change.path]) if parent_path else change.path + return parent_path + + @staticmethod + def _get_value_type_from_change( # pylint: disable=too-many-return-statements + change: t.Union[ + StackModels.DeploymentStacksChangeBase, + StackModels.DeploymentStacksChangeDeltaRecord, + StackModels.DeploymentStacksWhatIfPropertyChange] + ) -> t.Optional[t.Type]: + if hasattr(change, "change_type"): + if str_lower_eq(StackModels.DeploymentStacksWhatIfPropertyChangeType.ARRAY, change.change_type): + return list + if hasattr(change, "children") and change.children and len(change.children) > 0: + return dict + elif hasattr(change, "delta"): + return dict + + before_type = type(change.before) + after_type = type(change.after) + + if before_type == after_type: + return before_type + if after_type is not type(None): + return after_type + if before_type is not type(None): + return before_type + + return None + + @staticmethod + def _get_num_potential_resource_changes( + resource_changes: t.List[StackModels.DeploymentStacksWhatIfResourceChange], start_index: int + ) -> int: + count = 0 + for i in range(start_index, len(resource_changes)): + if str_lower_eq( + resource_changes[i].change_certainty, StackModels.DeploymentStacksWhatIfChangeCertainty.POTENTIAL): + count += 1 + else: + break + return count + + @staticmethod + def _format_resource_class_header(change: StackModels.DeploymentStacksWhatIfResourceChange) -> str: + if change.id: + return "Azure" + + result = "Unknown" + if change.extension: + result = f"{change.extension.name}@{change.extension.version}" + + if change.extension.config: + # Print the config. Eventually this can be substituted with an optional user-provided "comparison ID" + # for brevity. + config_items = sorted( + change.extension.config.items(), + key=lambda ci: ((ci[1] or {}).get('keyVaultReference', None) is not None, ci[0])) + + if len(config_items) > 0: + config_parts = [] + + for prop, item in config_items: + if not item: + continue + + if item.get('keyVaultReference', None): + secret_name = item['keyVaultReference'].get('secretName', None) + secret_version = item['keyVaultReference'].get('secretVersion', None) + kv_id = item['keyVaultReference'].get('keyVault', {}).get('id', None) + version_suffix = f"@{secret_version}" if secret_version else "" + + config_parts.append( + f"{prop}=") + else: + config_parts.append(f"{prop}={json.dumps(item.get('value', None))}") + + result += f" {', '.join(config_parts)}" + + return result + + @staticmethod + def _format_ext_resource_identifiers(identifiers: dict[str, t.Any]) -> str: + sorted_items = sorted(identifiers.items(), key=lambda x: x[0]) + + return ", ".join(f"{key}={json.dumps(value)}" for key, value in sorted_items) diff --git a/src/azure-cli/azure/cli/command_modules/resource/_utils.py b/src/azure-cli/azure/cli/command_modules/resource/_utils.py index 9feb1d93daa..fcffeea9fb8 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/_utils.py +++ b/src/azure-cli/azure/cli/command_modules/resource/_utils.py @@ -3,6 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import typing as t import re import json @@ -14,6 +15,10 @@ _relative_resource_id_pattern = r"^\/providers/(?P.+$)" +def str_lower_eq(str1: t.Optional[str], str2: t.Optional[str]): + return str1.lower() == str2.lower() if str1 and str2 else False + + def split_resource_id(resource_id): """Splits a fully qualified resource ID into two parts. diff --git a/src/azure-cli/azure/cli/command_modules/resource/commands.py b/src/azure-cli/azure/cli/command_modules/resource/commands.py index 968488eada1..90591d9caac 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/commands.py +++ b/src/azure-cli/azure/cli/command_modules/resource/commands.py @@ -442,15 +442,14 @@ def load_command_table(self, _): 'validate', 'validate_deployment_stack_at_management_group', validator=validate_deployment_stack_files, exception_handler=handle_template_based_exception) - # TODO(kylealbert): not ready for march 2026 - # with self.command_group('stack-whatif mg', resource_deploymentstacks_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTSTACKS) as g: - # g.custom_show_command('show', 'show_deployment_stack_what_if_at_management_group', table_transformer=transform_stacks) - # g.custom_command('list', 'list_deployment_stack_what_if_at_management_group', table_transformer=transform_stacks_list) - # g.custom_command('delete', 'delete_deployment_stack_what_if_at_management_group') - # g.custom_command( - # 'create', 'create_deployment_stack_what_if_at_management_group', supports_no_wait=True, - # validator=validate_deployment_stack_files, table_transformer=transform_stacks, - # exception_handler=handle_template_based_exception) + with self.command_group('stack-whatif mg', resource_deploymentstacks_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTSTACKS) as g: + g.custom_show_command('show', 'show_deployment_stack_what_if_at_management_group', table_transformer=transform_stacks) + g.custom_command('list', 'list_deployment_stack_what_if_at_management_group', table_transformer=transform_stacks_list) + g.custom_command('delete', 'delete_deployment_stack_what_if_at_management_group') + g.custom_command( + 'create', 'create_deployment_stack_what_if_at_management_group', supports_no_wait=True, + validator=validate_deployment_stack_files, table_transformer=transform_stacks, + exception_handler=handle_template_based_exception) with self.command_group('stack sub', resource_deploymentstacks_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTSTACKS) as g: g.custom_show_command('show', 'show_deployment_stack_at_subscription', table_transformer=transform_stacks) @@ -464,14 +463,13 @@ def load_command_table(self, _): 'validate', 'validate_deployment_stack_at_subscription', validator=validate_deployment_stack_files, exception_handler=handle_template_based_exception) - # TODO(kylealbert): not ready for march 2026 - # with self.command_group('stack-whatif sub', resource_deploymentstacks_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTSTACKS) as g: - # g.custom_show_command('show', 'show_deployment_stack_what_if_at_subscription', table_transformer=transform_stacks) - # g.custom_command('list', 'list_deployment_stack_what_if_at_subscription', table_transformer=transform_stacks_list) - # g.custom_command('delete', 'delete_deployment_stack_what_if_at_subscription') - # g.custom_command( - # 'create', 'create_deployment_stack_what_if_at_subscription', supports_no_wait=True, validator=validate_deployment_stack_files, - # table_transformer=transform_stacks, exception_handler=handle_template_based_exception) + with self.command_group('stack-whatif sub', resource_deploymentstacks_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTSTACKS) as g: + g.custom_show_command('show', 'show_deployment_stack_what_if_at_subscription', table_transformer=transform_stacks) + g.custom_command('list', 'list_deployment_stack_what_if_at_subscription', table_transformer=transform_stacks_list) + g.custom_command('delete', 'delete_deployment_stack_what_if_at_subscription') + g.custom_command( + 'create', 'create_deployment_stack_what_if_at_subscription', supports_no_wait=True, validator=validate_deployment_stack_files, + table_transformer=transform_stacks, exception_handler=handle_template_based_exception) with self.command_group('stack group', resource_deploymentstacks_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTSTACKS) as g: g.custom_show_command('show', 'show_deployment_stack_at_resource_group', table_transformer=transform_stacks) @@ -485,14 +483,13 @@ def load_command_table(self, _): 'validate', 'validate_deployment_stack_at_resource_group', validator=validate_deployment_stack_files, exception_handler=handle_template_based_exception) - # TODO(kylealbert): not ready for march 2026 - # with self.command_group('stack-whatif group', resource_deploymentstacks_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTSTACKS) as g: - # g.custom_show_command('show', 'show_deployment_stack_what_if_at_resource_group', table_transformer=transform_stacks) - # g.custom_command('list', 'list_deployment_stack_what_if_at_resource_group', table_transformer=transform_stacks_list) - # g.custom_command('delete', 'delete_deployment_stack_what_if_at_resource_group') - # g.custom_command( - # 'create', 'create_deployment_stack_what_if_at_resource_group', supports_no_wait=True, validator=validate_deployment_stack_files, - # table_transformer=transform_stacks, exception_handler=handle_template_based_exception) + with self.command_group('stack-whatif group', resource_deploymentstacks_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTSTACKS) as g: + g.custom_show_command('show', 'show_deployment_stack_what_if_at_resource_group', table_transformer=transform_stacks) + g.custom_command('list', 'list_deployment_stack_what_if_at_resource_group', table_transformer=transform_stacks_list) + g.custom_command('delete', 'delete_deployment_stack_what_if_at_resource_group') + g.custom_command( + 'create', 'create_deployment_stack_what_if_at_resource_group', supports_no_wait=True, validator=validate_deployment_stack_files, + table_transformer=transform_stacks, exception_handler=handle_template_based_exception) # az deployment group with self.command_group('deployment group', resource_deployment_sdk, resource_type=ResourceType.MGMT_RESOURCE_DEPLOYMENTS) as g: diff --git a/src/azure-cli/azure/cli/command_modules/resource/custom.py b/src/azure-cli/azure/cli/command_modules/resource/custom.py index 67bfae06581..41b87080f30 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/custom.py +++ b/src/azure-cli/azure/cli/command_modules/resource/custom.py @@ -44,6 +44,7 @@ from knack.util import CLIError from ._formatters import format_what_if_operation_result +from ._stacks_formatters import DeploymentStacksWhatIfResultFormatter from ._bicep import ( run_bicep_command, is_bicep_file, @@ -2913,11 +2914,26 @@ def _prepare_validate_stack_at_scope( return deployment_stack_model +def _print_deployment_stack_what_if_result(what_if_result, no_pretty_print, no_color): + # Check for an error result. + if what_if_result and what_if_result.properties and what_if_result.properties.error: + err_message = _build_preflight_error_message(what_if_result.properties.error) + raise_subdivision_deployment_error(err_message) + + if no_pretty_print: + return what_if_result + + formatter = DeploymentStacksWhatIfResultFormatter(enable_color=not no_color) + print(formatter.format(what_if_result)) + + return None + + def create_deployment_stack_what_if_at_resource_group( cmd, name, resource_group, stack_id, deny_settings_mode, action_on_unmanage, retention_interval, template_file=None, template_spec=None, template_uri=None, query_string=None, parameters=None, description=None, deny_settings_excluded_principals=None, deny_settings_excluded_actions=None, deny_settings_apply_to_child_scopes=False, resources_without_delete_support=None, - validation_level=None, tags=None + validation_level=None, tags=None, no_pretty_print=None, no_color=None ): rcf = _resource_deploymentstacks_client_factory(cmd.cli_ctx) @@ -2938,26 +2954,26 @@ def create_deployment_stack_what_if_at_resource_group( err_message = _build_http_response_error_message(err) raise_subdivision_deployment_error(err_message, err.error.code if err.error else None) - whatif_result = LongRunningOperation(cmd.cli_ctx)(whatif_poller) + what_if_result = LongRunningOperation(cmd.cli_ctx)(whatif_poller) - if whatif_result and whatif_result.properties and whatif_result.properties.error: - err_message = _build_preflight_error_message(whatif_result.properties.error) - raise_subdivision_deployment_error(err_message) - - # TODO(kylealbert): Return formatted view - return whatif_result + return _print_deployment_stack_what_if_result(what_if_result, no_pretty_print, no_color) -def show_deployment_stack_what_if_at_resource_group(cmd, name=None, resource_group=None, id=None): # pylint: disable=redefined-builtin +def show_deployment_stack_what_if_at_resource_group(cmd, name=None, resource_group=None, id=None, no_pretty_print=None, no_color=None): # pylint: disable=redefined-builtin rcf = _resource_deploymentstacks_client_factory(cmd.cli_ctx) - if name and resource_group: - return rcf.deployment_stacks_what_if_results_at_resource_group.get(resource_group, name) + if id: stack_arr = id.split('/') if len(stack_arr) < 5: raise InvalidArgumentValueError("Please enter a valid id") - return rcf.deployment_stacks_what_if_results_at_resource_group.get(stack_arr[4], stack_arr[-1]) - raise InvalidArgumentValueError("Please enter the (stack what-if result name and resource group) or stack what-if resource id") + + what_if_result = rcf.deployment_stacks_what_if_results_at_resource_group.get(stack_arr[4], stack_arr[-1]) + elif name and resource_group: + what_if_result = rcf.deployment_stacks_what_if_results_at_resource_group.get(resource_group, name) + else: + raise InvalidArgumentValueError("Please enter the (stack what-if result name and resource group) or stack what-if result resource id") + + return _print_deployment_stack_what_if_result(what_if_result, no_pretty_print, no_color) def list_deployment_stack_what_if_at_resource_group(cmd, resource_group): @@ -3004,7 +3020,7 @@ def create_deployment_stack_what_if_at_subscription( cmd, name, location, stack_id, deny_settings_mode, action_on_unmanage, retention_interval, deployment_resource_group=None, template_file=None, template_spec=None, template_uri=None, query_string=None, parameters=None, description=None, deny_settings_excluded_principals=None, deny_settings_excluded_actions=None, deny_settings_apply_to_child_scopes=False, - resources_without_delete_support=None, validation_level=None, tags=None + resources_without_delete_support=None, validation_level=None, tags=None, no_pretty_print=None, no_color=None ): rcf = _resource_deploymentstacks_client_factory(cmd.cli_ctx) @@ -3024,23 +3040,22 @@ def create_deployment_stack_what_if_at_subscription( err_message = _build_http_response_error_message(err) raise_subdivision_deployment_error(err_message, err.error.code if err.error else None) - whatif_result = LongRunningOperation(cmd.cli_ctx)(whatif_poller) - - if whatif_result and whatif_result.properties and whatif_result.properties.error: - err_message = _build_preflight_error_message(whatif_result.properties.error) - raise_subdivision_deployment_error(err_message) + what_if_result = LongRunningOperation(cmd.cli_ctx)(whatif_poller) - # TODO(kylealbert): Return formatted view - return whatif_result + return _print_deployment_stack_what_if_result(what_if_result, no_pretty_print, no_color) -def show_deployment_stack_what_if_at_subscription(cmd, name=None, id=None): # pylint: disable=redefined-builtin +def show_deployment_stack_what_if_at_subscription(cmd, name=None, id=None, no_pretty_print=None, no_color=None): # pylint: disable=redefined-builtin rcf = _resource_deploymentstacks_client_factory(cmd.cli_ctx) - if name or id: - if name: - return rcf.deployment_stacks_what_if_results_at_subscription.get(name) - return rcf.deployment_stacks_what_if_results_at_subscription.get(id.split('/')[-1]) - raise InvalidArgumentValueError("Please enter the stack what-if result name or stack what-if result resource id.") + + if name: + what_if_result = rcf.deployment_stacks_what_if_results_at_subscription.get(name) + elif id: + what_if_result = rcf.deployment_stacks_what_if_results_at_subscription.get(id.split('/')[-1]) + else: + raise InvalidArgumentValueError("Please enter the stack what-if result name or stack what-if result resource id.") + + return _print_deployment_stack_what_if_result(what_if_result, no_pretty_print, no_color) def list_deployment_stack_what_if_at_subscription(cmd): @@ -3077,7 +3092,8 @@ def create_deployment_stack_what_if_at_management_group( cmd, management_group_id, name, location, stack_id, deny_settings_mode, action_on_unmanage, retention_interval, deployment_subscription=None, template_file=None, template_spec=None, template_uri=None, query_string=None, parameters=None, description=None, deny_settings_excluded_principals=None, deny_settings_excluded_actions=None, - deny_settings_apply_to_child_scopes=False, resources_without_delete_support=None, validation_level=None, tags=None + deny_settings_apply_to_child_scopes=False, resources_without_delete_support=None, validation_level=None, tags=None, + no_pretty_print=None, no_color=None ): rcf = _resource_deploymentstacks_client_factory(cmd.cli_ctx) @@ -3098,23 +3114,22 @@ def create_deployment_stack_what_if_at_management_group( err_message = _build_http_response_error_message(err) raise_subdivision_deployment_error(err_message, err.error.code if err.error else None) - whatif_result = LongRunningOperation(cmd.cli_ctx)(whatif_poller) + what_if_result = LongRunningOperation(cmd.cli_ctx)(whatif_poller) - if whatif_result and whatif_result.properties and whatif_result.properties.error: - err_message = _build_preflight_error_message(whatif_result.properties.error) - raise_subdivision_deployment_error(err_message) + return _print_deployment_stack_what_if_result(what_if_result, no_pretty_print, no_color) - # TODO(kylealbert): Return formatted view - return whatif_result +def show_deployment_stack_what_if_at_management_group(cmd, management_group_id, name=None, id=None, no_pretty_print=None, no_color=None): # pylint: disable=redefined-builtin + rcf = _resource_deploymentstacks_client_factory(cmd.cli_ctx) -def show_deployment_stack_what_if_at_management_group(cmd, management_group_id, name=None, id=None): # pylint: disable=redefined-builtin - if name or id: - rcf = _resource_deploymentstacks_client_factory(cmd.cli_ctx) - if name: - return rcf.deployment_stacks_what_if_results_at_management_group.get(management_group_id, name) - return rcf.deployment_stacks_what_if_results_at_management_group.get(management_group_id, id.split('/')[-1]) - raise InvalidArgumentValueError("Please enter the stack what-if result name or stack what-if result resource id.") + if name: + what_if_result = rcf.deployment_stacks_what_if_results_at_management_group.get(management_group_id, name) + elif id: + what_if_result = rcf.deployment_stacks_what_if_results_at_management_group.get(management_group_id, id.split('/')[-1]) + else: + raise InvalidArgumentValueError("Please enter the stack what-if result name or stack what-if result resource id.") + + return _print_deployment_stack_what_if_result(what_if_result, no_pretty_print, no_color=no_color) def list_deployment_stack_what_if_at_management_group(cmd, management_group_id): diff --git a/src/azure-cli/azure/cli/command_modules/resource/linter_exclusions.yml b/src/azure-cli/azure/cli/command_modules/resource/linter_exclusions.yml index 363c0602902..148f0235c62 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/linter_exclusions.yml +++ b/src/azure-cli/azure/cli/command_modules/resource/linter_exclusions.yml @@ -27,3 +27,21 @@ stack sub validate: stack mg: rule_exclusions: - require_wait_command_if_no_wait + +stack-whatif group: + rule_exclusions: + - require_wait_command_if_no_wait + +stack-whatif sub: + rule_exclusions: + - require_wait_command_if_no_wait + +stack-whatif sub create: + parameters: + deployment_resource_group: + rule_exclusions: + - parameter_should_not_end_in_resource_group + +stack-whatif mg: + rule_exclusions: + - require_wait_command_if_no_wait diff --git a/src/azure-cli/azure/cli/command_modules/resource/tests/latest/data/stacks-what-if/what-if-1.json b/src/azure-cli/azure/cli/command_modules/resource/tests/latest/data/stacks-what-if/what-if-1.json new file mode 100644 index 00000000000..bacc5457869 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/resource/tests/latest/data/stacks-what-if/what-if-1.json @@ -0,0 +1,498 @@ +{ + "properties": { + "deploymentStackResourceId": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Resources/deploymentStacks/testStack_9ef16884f0dad7d0e5de3d3ec57", + "retentionInterval": "P1D", + "provisioningState": "Succeeded", + "deploymentStackLastModified": "2026-02-23T16:44:05+00:00", + "deploymentExtensions": [ + { + "name": "Contoso", + "version": "2.0.0" + }, + { + "name": "Kubernetes", + "version": "2.0.0", + "configId": "kubeConfigHash", + "config": { + "kubeconfig": { + "keyVaultReference": { + "keyVault": { + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/myRg/providers/Microsoft.KeyVault/vaults/kubeConfigVault" + }, + "secretName": "kubeConfigSecretName" + } + }, + "namespace": { + "value": "default" + } + } + } + ], + "changes": { + "resourceChanges": [ + { + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "type": "Microsoft.Test/testA", + "managementStatusChange": { + "before": "Managed", + "after": "Managed" + }, + "denyStatusChange": { + "before": "None", + "after": "DenyDelete" + }, + "resourceConfigurationChanges": { + "before": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "location": "westus", + "name": "resourceA", + "type": "Microsoft.Test/testA", + "properties": { + "property1": "resourceA-before" + } + }, + "after": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "location": "westus", + "name": "resourceA", + "type": "Microsoft.Test/testA", + "properties": { + "property1": "resourceA-after" + } + }, + "delta": [ + { + "path": "properties.properties1", + "changeType": "Modify", + "before": "resourceA-before", + "after": "resourceA-after", + "children": [] + } + ] + }, + "changeType": "Modify", + "changeCertainty": "Definite" + }, + { + "changeType": "Delete", + "changeCertainty": "Definite", + "extension": { + "name": "Contoso", + "version": "2.0.0" + }, + "type": "Contoso/example", + "identifiers": { + "name": "defResource" + }, + "managementStatusChange": { + "before": null, + "after": "Managed" + }, + "denyStatusChange": { + "before": null, + "after": "NotApplicable" + }, + "resourceConfigurationChanges": { + "before": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "location": "westus", + "name": "resourceA", + "type": "Microsoft.Test/testA", + "properties": { + "property1": "resourceA-before" + } + }, + "after": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "location": "westus", + "name": "resourceA", + "type": "Microsoft.Test/testA", + "properties": { + "property1": "resourceA-after" + } + }, + "delta": [ + { + "path": "properties.properties1", + "changeType": "Modify", + "before": "resourceA-before", + "after": "resourceA-after", + "children": [] + } + ] + } + }, + { + "changeType": "Modify", + "changeCertainty": "Definite", + "extension": { + "name": "Contoso", + "version": "2.0.0" + }, + "type": "Contoso/example", + "identifiers": { + "name": "abcResource" + }, + "managementStatusChange": { + "before": null, + "after": "Managed" + }, + "denyStatusChange": { + "before": null, + "after": "NotApplicable" + }, + "resourceConfigurationChanges": { + "before": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "location": "westus", + "name": "resourceA", + "type": "Microsoft.Test/testA", + "properties": { + "property1": "resourceA-before" + } + }, + "after": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "location": "westus", + "name": "resourceA", + "type": "Microsoft.Test/testA", + "properties": { + "property1": "resourceA-after" + } + }, + "delta": [ + { + "path": "properties.properties1", + "changeType": "Modify", + "before": "resourceA-before", + "after": "resourceA-after", + "children": [] + } + ] + } + }, + { + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testB/resourceB", + "type": "Microsoft.Test/testB", + "managementStatusChange": { + "before": "Managed", + "after": "Managed" + }, + "denyStatusChange": { + "before": "None", + "after": "DenyDelete" + }, + "resourceConfigurationChanges": { + "before": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testB/resourceB", + "location": "westus", + "name": "resourceB", + "type": "Microsoft.Test/testB", + "properties": { + "property1": "resourceB" + } + }, + "after": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testB/resourceB", + "location": "westus", + "name": "resourceB", + "type": "Microsoft.Test/testB", + "properties": { + "property1": "resourceB" + } + }, + "delta": [] + }, + "changeType": "NoChange", + "changeCertainty": "Definite" + }, + { + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testD/resourceD", + "type": "Microsoft.Test/testD", + "managementStatusChange": { + "before": "NotManaged", + "after": "Managed" + }, + "denyStatusChange": { + "before": "None", + "after": "DenyDelete" + }, + "resourceConfigurationChanges": { + "after": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testD/resourceD", + "location": "westus", + "name": "resourceD", + "type": "Microsoft.Test/testD", + "properties": { + "property1": "resourceD" + } + }, + "delta": [] + }, + "changeType": "Create", + "changeCertainty": "Definite" + }, + { + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testC/resourceC", + "type": "Microsoft.Test/testC", + "managementStatusChange": { + "before": "Managed", + "after": "Managed" + }, + "denyStatusChange": { + "before": "None", + "after": "DenyDelete" + }, + "resourceConfigurationChanges": { + "before": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testC/resourceC", + "location": "westus", + "name": "resourceC", + "type": "Microsoft.Test/testC", + "properties": { + "property1": "resourceC-before" + } + }, + "after": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testC/resourceC", + "location": "westus", + "name": "resourceC", + "type": "Microsoft.Test/testC", + "properties": { + "property1": "resourceC-potential-after" + } + }, + "delta": [ + { + "path": "properties.properties1", + "changeType": "Modify", + "before": "resourceC-before", + "after": "resourceC-potential-after", + "children": [] + } + ] + }, + "changeType": "Modify", + "changeCertainty": "Potential" + }, + { + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testC/resourceC", + "type": "Microsoft.Test/testC", + "managementStatusChange": { + "before": "Managed", + "after": "NotManaged" + }, + "denyStatusChange": { + "before": "None", + "after": "None" + }, + "changeType": "Delete", + "changeCertainty": "Potential" + }, + { + "changeType": "Create", + "changeCertainty": "Definite", + "extension": { + "name": "Kubernetes", + "version": "2.0.0", + "configId": "kubeConfigHash", + "config": { + "namespace": { + "value": "myNs" + }, + "kubeconfig": { + "keyVaultReference": { + "keyVault": { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myResourceGroup/providers/Microsoft.KeyVault/vaults/myKeyVault" + }, + "secretName": "mySecret" + } + } + } + }, + "type": "app/Deployment", + "identifiers": { + "name": "abcResource" + }, + "managementStatusChange": { + "before": null, + "after": "Managed" + }, + "denyStatusChange": { + "before": null, + "after": "NotApplicable" + }, + "resourceConfigurationChanges": { + "before": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "location": "westus", + "name": "resourceA", + "type": "Microsoft.Test/testA", + "properties": { + "property1": "resourceA-before" + } + }, + "after": { + "apiVersion": "2021-05-01", + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA", + "location": "westus", + "name": "resourceA", + "type": "Microsoft.Test/testA", + "properties": { + "property1": "resourceA-after" + } + }, + "delta": [ + { + "path": "properties.properties1", + "changeType": "Modify", + "before": "resourceA-before", + "after": "resourceA-after", + "children": [] + } + ] + } + } + ], + "deploymentScopeChange": { + "before": "ThisIsBefore", + "after": "ThisIsAfter" + }, + "denySettingsChange": { + "before": { + "mode": "None", + "excludedPrincipals": [], + "excludedActions": [], + "applyToChildScopes": false + }, + "after": { + "mode": "DenyDelete", + "excludedPrincipals": [ + "004afc20-146e-4932-a8b5-3098461c46a5", + "e6a513a0-b872-4355-82b9-47645fb30d3a" + ], + "excludedActions": [], + "applyToChildScopes": true + }, + "delta": [ + { + "path": "Mode", + "changeType": "Modify", + "before": "None", + "after": "DenyDelete" + }, + { + "path": "ApplyToChildScopes", + "changeType": "Modify", + "before": "False", + "after": "True" + }, + { + "path": "ExcludedPrincipals", + "changeType": "Array", + "children": [ + { + "changeType": "Create", + "after": "004afc20-146e-4932-a8b5-3098461c46a5" + }, + { + "changeType": "Create", + "after": "e6a513a0-b872-4355-82b9-47645fb30d3a" + } + ] + }, + { + "path": "ArrayOfMixed", + "changeType": "Array", + "children": [ + { + "path": "0", + "changeType": "Modify", + "children": [ + { + "path": "properties.something", + "changeType": "Modify", + "before": "B4", + "after": "Now" + } + ] + }, + { + "path": "1", + "changeType": "Create", + "after": "now", + "before": "b4" + }, + { + "path": "2", + "changeType": "Delete", + "before": "iWasDeleted" + } + ] + } + ] + } + }, + "diagnostics": [ + { + "level": "Warning", + "code": "NoSupportForExtensibleResources", + "message": "Extensible resources are currently not supported" + }, + { + "level": "Warning", + "code": "Abc", + "message": "Xyz" + }, + { + "level": "Info", + "code": "InfoCode", + "message": "InfoMessage" + }, + { + "level": "Error", + "code": "ErrorCode", + "message": "ErrorMessage" + } + ], + "correlationId": "14958d2b-08d9-4b9a-a9ce-7fcfa172fda5", + "actionOnUnmanage": { + "resources": "Delete", + "resourceGroups": "Detach", + "managementGroups": "Detach", + "resourcesWithoutDeleteSupport": "Fail" + }, + "deploymentScope": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1", + "denySettings": { + "mode": "DenyDelete", + "applyToChildScopes": true, + "excludedPrincipals": [ + "004afc20-146e-4932-a8b5-3098461c46a5", + "e6a513a0-b872-4355-82b9-47645fb30d3a" + ], + "excludedActions": [] + }, + "parametersLink": { + "uri": "https://MyParameters.json" + }, + "templateLink": { + "uri": "https://MyTemplate.json" + }, + "bypassStackOutOfSyncError": false + }, + "id": "/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Resources/deploymentStacksWhatIfResults/testWhatIf_c04b8bc4f3cbe14fb0670419042", + "type": "Microsoft.Resources/deploymentStacksWhatIfResults", + "name": "testWhatIf_c04b8bc4f3cbe14fb0670419042" +} \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/resource/tests/latest/test_resource_stacks.py b/src/azure-cli/azure/cli/command_modules/resource/tests/latest/test_resource_stacks.py new file mode 100644 index 00000000000..7acee83ebb9 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/resource/tests/latest/test_resource_stacks.py @@ -0,0 +1,98 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os + +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer + + +class DeploymentStacksWhatIfTest(ScenarioTest): + LOCATION = "westcentralus" + MGMT_GROUP_NAME = "AzBlueprintAssignTest" + + @ResourceGroupPreparer(name_prefix='cli_test_stacks_what_if', location=LOCATION) + def test_deployment_stack_what_if_at_resource_group(self, resource_group): + stack_what_if_name = self.create_random_name('cli-test-create-rg-stack-what-if', 60) + stack_name = self.create_random_name('cli-test-create-rg-stack-for-what-if', 60) + + self.kwargs.update({ + 'name': stack_what_if_name, + 'location': DeploymentStacksWhatIfTest.LOCATION, + 'resource-group': resource_group, + 'template-file': self._get_test_file('simple_template.json'), + 'parameter-file': self._get_test_file('simple_template_params.json'), + 'stack-id': f'/subscriptions/{self.get_subscription_id()}/resourceGroups/{resource_group}/providers/Microsoft.Resources/deploymentStacks/{stack_name}', + }) + + self.cmd( + 'stack-whatif group create --name {name} --resource-group {resource-group} --template-file "{template-file}" --deny-settings-mode denYdeletE --parameters "{parameter-file}" --yes --description "stack deployment" --aou deleteAll --deny-settings-excluded-principals "principal1 principal2" --deny-settings-excluded-actions "action1 action2" --deny-settings-apply-to-child-scopes --vl ProviderNoRbac --ri P1D --stack "{stack-id}"', + checks=self.check('provisioningState', 'succeeded')) + + self.cmd( + 'stack-whatif group show --name {name} --resource-group {resource-group}', + checks=self.check('provisioningState', 'succeeded')) + + self.cmd( + 'stack-whatif group list --resource-group {resource-group}', + checks=self.check(f"length([?name=='{stack_what_if_name}']) > `0`", True)) + + self.cmd('stack-whatif group delete --name {name} --resource-group {resource-group} --yes') + + def test_deployment_stack_what_if_at_subscription(self): + stack_what_if_name = self.create_random_name('cli-test-create-sub-stack-what-if', 60) + stack_name = self.create_random_name('cli-test-create-sub-stack-for-what-if', 60) + + self.kwargs.update({ + 'name': stack_what_if_name, + 'location': DeploymentStacksWhatIfTest.LOCATION, + 'template-file': self._get_test_file('template_sub_validate.json'), + 'parameter-file': self._get_test_file('template_sub_validate_parameters_valid.json'), + 'stack-id': f'/subscriptions/{self.get_subscription_id()}/providers/Microsoft.Resources/deploymentStacks/{stack_name}', + }) + + self.cmd( + 'stack-whatif sub create --name {name} --location {location} --template-file "{template-file}" --dm denyDelete --parameters "{parameter-file}" --yes --description "stack deployment" --aou deleteAll --deny-settings-excluded-principals "principal1 principal2" --deny-settings-excluded-actions "action1 action2" --deny-settings-apply-to-child-scopes --vl ProviderNoRbac --ri P1D --stack "{stack-id}"', + checks=self.check('provisioningState', 'succeeded')) + + self.cmd( + 'stack-whatif sub show --name {name}', + checks=self.check('provisioningState', 'succeeded')) + + self.cmd( + 'stack-whatif sub list', + checks=self.check(f"length([?name=='{stack_what_if_name}']) > `0`", True)) + + self.cmd('stack-whatif sub delete --name {name} --yes') + + def test_deployment_stack_what_if_at_management_group(self): + stack_what_if_name = self.create_random_name('cli-test-create-mg-stack-what-if', 60) + stack_name = self.create_random_name('cli-test-create-mg-stack-for-what-if', 60) + + self.kwargs.update({ + 'name': stack_what_if_name, + 'location': DeploymentStacksWhatIfTest.LOCATION, + 'management-group': DeploymentStacksWhatIfTest.MGMT_GROUP_NAME, + 'template-file': self._get_test_file('template_mg_validate.json'), + 'parameter-file': self._get_test_file('template_mg_validate_parameters_valid.json'), + 'stack-id': f'/providers/Microsoft.Management/managementGroups/{DeploymentStacksWhatIfTest.MGMT_GROUP_NAME}/providers/Microsoft.Resources/deploymentStacks/{stack_name}', + }) + + self.cmd( + 'stack-whatif mg create --name {name} --location {location} --management-group-id {management-group} --template-file "{template-file}" --dm denyDelete --parameters "{parameter-file}" --yes --description "stack deployment" --aou deleteAll --deny-settings-excluded-principals "principal1 principal2" --deny-settings-excluded-actions "action1 action2" --deny-settings-apply-to-child-scopes --vl ProviderNoRbac --ri P1D --stack "{stack-id}"', + checks=self.check('provisioningState', 'succeeded')) + + self.cmd( + 'stack-whatif mg show --name {name} --management-group-id {management-group}', + checks=self.check('provisioningState', 'succeeded')) + + self.cmd( + 'stack-whatif mg list --management-group-id {management-group}', + checks=self.check(f"length([?name=='{stack_what_if_name}']) > `0`", True)) + + self.cmd('stack-whatif mg delete --name {name} --management-group-id {management-group} --yes') + + @staticmethod + def _get_test_file(file_path: str): + return os.path.join(os.path.dirname(os.path.realpath(__file__)), file_path).replace('\\', '\\\\') \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/resource/tests/latest/test_stack_formatters.py b/src/azure-cli/azure/cli/command_modules/resource/tests/latest/test_stack_formatters.py new file mode 100644 index 00000000000..1a047ed88ee --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/resource/tests/latest/test_stack_formatters.py @@ -0,0 +1,115 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import os +import unittest + +import azure.mgmt.resource.deploymentstacks.models as StackModels + +from azure.cli.command_modules.resource._color import Color +from azure.cli.command_modules.resource._stacks_formatters import DeploymentStacksWhatIfResultFormatter + + +class TestStacksWhatIfResultFormatter(unittest.TestCase): + + def test_what_if_1(self): + what_if_result = self._get_stacks_what_if_result("what-if-1.json") + + self.assertEqual(self.EXPECTED_STACKS_WHAT_IF_1, DeploymentStacksWhatIfResultFormatter().format(what_if_result)) + + expected_no_color_result = self.EXPECTED_STACKS_WHAT_IF_1 + for color in list(Color): + expected_no_color_result = expected_no_color_result.replace(str(color), '') + + self.assertEqual( + expected_no_color_result, DeploymentStacksWhatIfResultFormatter(enable_color=False).format(what_if_result)) + + def _get_stacks_what_if_result(self, file_name: str): + return StackModels.DeploymentStacksWhatIfResult(self._get_stacks_what_if_json(file_name)) + + @staticmethod + def _get_stacks_what_if_json(file_name: str): + with open(TestStacksWhatIfResultFormatter._get_stacks_what_if_test_file_path(file_name), 'r') as f: + return json.load(f) + + @staticmethod + def _get_stacks_what_if_test_file_path(file_name: str): + curr_dir = os.path.dirname(os.path.realpath(__file__)) + return os.path.join(os.path.join(curr_dir, 'data\\stacks-what-if'), file_name).replace('\\', '\\\\') + + EXPECTED_STACKS_WHAT_IF_1 = f"""Resource and property changes are indicated with these symbols: + {Color.GREEN}+{Color.RESET} Create ! Unsupported + {Color.PURPLE}~{Color.RESET} Modify {Color.RED}-{Color.RESET} Delete + = NoChange {Color.BLUE}v{Color.RESET} Detach + +{Color.DARK_YELLOW}Changes to Stack /subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Resources/deploymentStacks/testStack_9ef16884f0dad7d0e5de3d3ec57:{Color.RESET} +{Color.PURPLE}~ DeploymentScope: {Color.RESET}"ThisIsBefore" => "ThisIsAfter" +{Color.PURPLE}~ DenySettings.Mode: {Color.RESET}"None" => "DenyDelete" +{Color.PURPLE}~ DenySettings.ApplyToChildScopes: {Color.RESET}"False" => "True" +{Color.PURPLE}~ DenySettings.ExcludedPrincipals: {Color.RESET} + {Color.GREEN}+ {Color.RESET}{Color.GREEN}"004afc20-146e-4932-a8b5-3098461c46a5"{Color.RESET} + {Color.GREEN}+ {Color.RESET}{Color.GREEN}"e6a513a0-b872-4355-82b9-47645fb30d3a"{Color.RESET} +{Color.PURPLE}~ DenySettings.ArrayOfMixed: {Color.RESET} + {Color.PURPLE}~{Color.RESET} 0: + {Color.PURPLE}~ properties.something: {Color.RESET}"B4" => "Now" + {Color.GREEN}+{Color.RESET} 1: + {Color.GREEN}+ {Color.RESET}{Color.GREEN}"now"{Color.RESET} + {Color.RED}-{Color.RESET} 2: + {Color.RED}- {Color.RESET}{Color.RED}"iWasDeleted"{Color.RESET} + +{Color.DARK_YELLOW}Changes to Managed Resources:{Color.RESET} + +Azure +{Color.PURPLE}~ {Color.RESET}{Color.PURPLE}/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testA/resourceA{Color.RESET} + = Management Status: "Managed" + {Color.PURPLE}~ Deny Status: {Color.RESET}"None" => "DenyDelete" + {Color.PURPLE}~ properties.properties1: {Color.RESET}"resourceA-before" => "resourceA-after" += /subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testB/resourceB + = Management Status: "Managed" + {Color.PURPLE}~ Deny Status: {Color.RESET}"None" => "DenyDelete" +{Color.GREEN}+ {Color.RESET}{Color.GREEN}/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testD/resourceD{Color.RESET} + {Color.PURPLE}~ Management Status: {Color.RESET}"NotManaged" => "Managed" + {Color.PURPLE}~ Deny Status: {Color.RESET}"None" => "DenyDelete" +>> {Color.PURPLE}Potential Resource Changes (Learn more at https://aka.ms/whatIfPotentialChanges){Color.RESET} +{Color.CYAN}?{Color.RESET}{Color.PURPLE}~ {Color.RESET}{Color.CYAN}[Potential] {Color.RESET}{Color.PURPLE}/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testC/resourceC{Color.RESET} + = Management Status: "Managed" + {Color.PURPLE}~ Deny Status: {Color.RESET}"None" => "DenyDelete" + {Color.PURPLE}~ properties.properties1: {Color.RESET}"resourceC-before" => "resourceC-potential-after" +{Color.CYAN}?{Color.RESET}{Color.RED}- {Color.RESET}{Color.CYAN}[Potential] {Color.RESET}{Color.RED}/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testC/resourceC{Color.RESET} + {Color.PURPLE}~ Management Status: {Color.RESET}"Managed" => "NotManaged" + = Deny Status: "None" + +Contoso@2.0.0 +{Color.PURPLE}~ {Color.RESET}{Color.PURPLE}Contoso/example name="abcResource"{Color.RESET} + {Color.PURPLE}~ Management Status: {Color.RESET}null => "Managed" + {Color.PURPLE}~ Deny Status: {Color.RESET}null => "NotApplicable" + {Color.PURPLE}~ properties.properties1: {Color.RESET}"resourceA-before" => "resourceA-after" +{Color.RED}- {Color.RESET}{Color.RED}Contoso/example name="defResource"{Color.RESET} + {Color.PURPLE}~ Management Status: {Color.RESET}null => "Managed" + {Color.PURPLE}~ Deny Status: {Color.RESET}null => "NotApplicable" + {Color.PURPLE}~ properties.properties1: {Color.RESET}"resourceA-before" => "resourceA-after" + +Kubernetes@2.0.0 namespace="myNs", kubeconfig= +{Color.GREEN}+ {Color.RESET}{Color.GREEN}app/Deployment name="abcResource"{Color.RESET} + {Color.PURPLE}~ Management Status: {Color.RESET}null => "Managed" + {Color.PURPLE}~ Deny Status: {Color.RESET}null => "NotApplicable" + {Color.PURPLE}~ properties.properties1: {Color.RESET}"resourceA-before" => "resourceA-after" + +{Color.RED}Deleting - {Color.RESET}Resources Marked for Deletion 2 total: + +Azure +>> {Color.RED}Potential Deletions 1 total (Learn more at https://aka.ms/whatIfPotentialChanges){Color.RESET} +{Color.CYAN}?{Color.RESET}{Color.RED}- {Color.RESET}{Color.CYAN}[Potential] {Color.RESET}{Color.RED}/subscriptions/6d41d86d-eb6b-473a-b31d-bbd084e1814d/resourceGroups/503ace4c-9b1c-4059-a3e9-09553d24e9e1/providers/Microsoft.Test/testC/resourceC{Color.RESET} + +Contoso@2.0.0 +{Color.RED}- {Color.RESET}{Color.RED}Contoso/example name="defResource"{Color.RESET} + +Diagnostics (4): +INFO: [InfoCode] InfoMessage +{Color.DARK_YELLOW}WARNING: [Abc] Xyz{Color.RESET} +{Color.DARK_YELLOW}WARNING: [NoSupportForExtensibleResources] Extensible resources are currently not supported{Color.RESET} +{Color.RED}ERROR: [ErrorCode] ErrorMessage{Color.RESET} +""" diff --git a/src/azure-cli/service_name.json b/src/azure-cli/service_name.json index e260e066dc8..adf2d01e4f1 100644 --- a/src/azure-cli/service_name.json +++ b/src/azure-cli/service_name.json @@ -194,6 +194,11 @@ "AzureServiceName": "Resource Manager", "URL": "https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks" }, + { + "Command": "az stack-whatif", + "AzureServiceName": "Resource Manager", + "URL": "https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks" + }, { "Command": "az devops", "AzureServiceName": "DevOps",