From abb2f172d5319aa65f13288f908d2ec53b9631ac Mon Sep 17 00:00:00 2001 From: MoChilia Date: Thu, 11 Sep 2025 17:03:14 +0800 Subject: [PATCH 01/28] az what-if (save) --- .../azure/cli/command_modules/util/commands.py | 3 +++ .../azure/cli/command_modules/util/custom.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/azure-cli/azure/cli/command_modules/util/commands.py b/src/azure-cli/azure/cli/command_modules/util/commands.py index 6f0c14030fc..396d9e6a390 100644 --- a/src/azure-cli/azure/cli/command_modules/util/commands.py +++ b/src/azure-cli/azure/cli/command_modules/util/commands.py @@ -22,3 +22,6 @@ def load_command_table(self, _): with self.command_group('demo secret-store') as g: g.custom_command('save', 'secret_store_save') g.custom_command('load', 'secret_store_load') + + with self.command_group('') as g: + g.custom_command('what-if', 'show_what_if', is_preview=True) diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index ea2f2ea0bd0..05c1db77f5d 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -370,3 +370,16 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument from azure.cli.core.auth.util import AccessToken # Assume the access token expires in 1 year / 31536000 seconds return AccessToken(self.access_token, int(time.time()) + 31536000) + +def show_what_if(cmd, cli_ctx, azcli_script): # pylint: disable=unused-argument + FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" + subscription_id = cmd.cli_ctx.subscription_id + payload = { + "azcli_script": azcli_script, + "subscription_id": subscription_id + } + from azure.cli.core.util import send_raw_request + response = send_raw_request(cli_ctx, "GET", f"{FUNCTION_APP_URL}/api/what_if_preview", json=payload) + if response.status_code == 200: + results = response.json() + return results From 46cf88dd7fc1d695519ec0bb38027de0fbc68c47 Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Sun, 14 Sep 2025 10:54:57 +0800 Subject: [PATCH 02/28] az what-if --- .../azure/cli/command_modules/util/_help.py | 8 ++++ .../azure/cli/command_modules/util/_params.py | 3 ++ .../azure/cli/command_modules/util/custom.py | 41 ++++++++++++++++--- .../util/tests/latest/test_whatif_script.sh | 2 + 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif_script.sh diff --git a/src/azure-cli/azure/cli/command_modules/util/_help.py b/src/azure-cli/azure/cli/command_modules/util/_help.py index 84c2a529b68..90e3bba5062 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_help.py +++ b/src/azure-cli/azure/cli/command_modules/util/_help.py @@ -92,3 +92,11 @@ - name: List resource groups by bringing your own access token text: az demo byo-access-token --access-token "eyJ0eXAiO..." --subscription-id 00000000-0000-0000-0000-000000000000 """ + +helps['what-if'] = """ +type: command +short-summary: Creates a sandboxed what-if simulation of Azure CLI scripts to visualize infrastructure changes before execution. +examples: +- name: Simulate a what-if scenario for a resource group deletion + text: az what-if --script-path "/path/to/your/script.sh" +""" \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/util/_params.py b/src/azure-cli/azure/cli/command_modules/util/_params.py index 3e0aae88cd1..687b3aa9a71 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_params.py +++ b/src/azure-cli/azure/cli/command_modules/util/_params.py @@ -51,3 +51,6 @@ def load_arguments(self, _): with self.argument_context('demo byo-access-token') as c: c.argument('access_token', help="Your own access token") c.argument('subscription_id', help="Subscription ID under which to list resource groups") + + with self.argument_context('what-if') as c: + c.argument('script_path', help="Path to a script file containing Azure CLI commands to be executed.") diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index 05c1db77f5d..ce4ab365e84 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -371,15 +371,46 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument # Assume the access token expires in 1 year / 31536000 seconds return AccessToken(self.access_token, int(time.time()) + 31536000) -def show_what_if(cmd, cli_ctx, azcli_script): # pylint: disable=unused-argument +def show_what_if(cmd, script_path): FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" - subscription_id = cmd.cli_ctx.subscription_id + + try: + with open(script_path, 'r', encoding='utf-8') as f: + script_content = f.read() + except FileNotFoundError: + raise CLIError(f"Script file not found: {script_path}") + except Exception as ex: + raise CLIError(f"Error reading script file: {ex}") + + from azure.cli.core.commands.client_factory import get_subscription_id + subscription_id = get_subscription_id(cmd.cli_ctx) + payload = { - "azcli_script": azcli_script, + "azcli_script": script_content, "subscription_id": subscription_id } from azure.cli.core.util import send_raw_request - response = send_raw_request(cli_ctx, "GET", f"{FUNCTION_APP_URL}/api/what_if_preview", json=payload) + import json + + try: + response = send_raw_request(cmd.cli_ctx, "POST", f"{FUNCTION_APP_URL}/api/what_if_preview", + body=json.dumps(payload), resource="https://management.azure.com") + except Exception as ex: + raise CLIError(f"Failed to connect to the what-if service: {ex}") + if response.status_code == 200: - results = response.json() + try: + results = response.json() + except ValueError as ex: + raise CLIError(f"Failed to parse response from what-if service: {ex}") + else: + error_msg = f"HTTP {response.status_code}: Request failed" + try: + error_detail = response.text + if error_detail: + error_msg += f" - {error_detail}" + except Exception: + pass + raise CLIError(error_msg) + return results diff --git a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif_script.sh b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif_script.sh new file mode 100644 index 00000000000..124797a2b26 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif_script.sh @@ -0,0 +1,2 @@ +# Create a VM directly instead of using an ARM template +az vm create --resource-group myrg --name MyVM_01 --image UbuntuLTS --size Standard_D2s_v3 --admin-username azureuser --generate-ssh-keys From 7bc0289509396410b5eab3fe941b72831485983f Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Wed, 17 Sep 2025 10:24:17 +0800 Subject: [PATCH 03/28] Add what if feature --- .../azure/cli/core/commands/__init__.py | 43 ++++++- .../azure/cli/core/commands/parameters.py | 9 ++ src/azure-cli-core/azure/cli/core/what_if.py | 109 ++++++++++++++++++ .../azure/cli/command_modules/sql/_params.py | 2 + .../azure/cli/command_modules/sql/custom.py | 1 + .../azure/cli/command_modules/vm/_params.py | 4 +- .../azure/cli/command_modules/vm/custom.py | 2 +- 7 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/what_if.py diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index fb2a9a3dece..f9e91b197f2 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -506,6 +506,8 @@ class AzCliCommandInvoker(CommandInvoker): # pylint: disable=too-many-statements,too-many-locals,too-many-branches def execute(self, args): + args_copy = args[:] + from knack.events import (EVENT_INVOKER_PRE_CMD_TBL_CREATE, EVENT_INVOKER_POST_CMD_TBL_CREATE, EVENT_INVOKER_CMD_TBL_LOADED, EVENT_INVOKER_PRE_PARSE_ARGS, EVENT_INVOKER_POST_PARSE_ARGS, @@ -586,7 +588,8 @@ def execute(self, args): args[0] = '--help' self.parser.enable_autocomplete() - + if '--what-if' in (args_copy): + return self._what_if(args_copy) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_PARSE_ARGS, args=args) parsed_args = self.parser.parse_args(args) self.cli_ctx.raise_event(EVENT_INVOKER_POST_PARSE_ARGS, command=parsed_args.command, args=parsed_args) @@ -691,6 +694,44 @@ def execute(self, args): table_transformer=self.commands_loader.command_table[parsed_args.command].table_transformer, is_query_active=self.data['query_active']) + def _what_if(self, args): + # DEBUG: Add logging to see if this method is called + print(f"DEBUG: _what_if called with command: {args}") + if '--what-if' in args: + print("DEBUG: Entering what-if mode") + from azure.cli.core.what_if import what_if_preview + try: + # Get subscription ID with priority: --subscription parameter > current login subscription + if '--subscription' in args: + index = args.index('--subscription') + if index + 1 < len(args): + subscription_value = args[index + 1] + subscription_id = subscription_value + else: + # Fallback to current login subscription TODO + subscription_id = self.cli_ctx.data.get("subscription_id", "6b085460-5f21-477e-ba44-1035046e9101") + + args = ["az"] + args if args[0] != 'az' else args + command = " ".join(args) + what_if_result = what_if_preview(command, subscription_id=subscription_id) + + # Ensure output format is set for proper formatting + # Default to 'json' if not already set + if 'output' not in self.cli_ctx.invocation.data or self.cli_ctx.invocation.data['output'] is None: + self.cli_ctx.invocation.data['output'] = 'json' + + # Return the formatted what-if output as the result + # Similar to the normal flow in execute() method + return CommandResultItem( + what_if_result, + table_transformer=None, + is_query_active=self.data.get('query_active', False), + exit_code=0 + ) + except Exception as ex: + # If what-if service fails, still show an informative message + return CommandResultItem(None, exit_code=1, error=CLIError('What-if preview failed: {str(ex)}\nNote: This was a preview operation. No actual changes were made.')) + @staticmethod def _extract_parameter_names(args): # note: name start with more than 2 '-' will be treated as value e.g. certs in PEM format diff --git a/src/azure-cli-core/azure/cli/core/commands/parameters.py b/src/azure-cli-core/azure/cli/core/commands/parameters.py index c098d1a42a1..0905238cb71 100644 --- a/src/azure-cli-core/azure/cli/core/commands/parameters.py +++ b/src/azure-cli-core/azure/cli/core/commands/parameters.py @@ -268,6 +268,15 @@ def get_location_type(cli_ctx): return location_type +def get_what_if_type(): + what_if_type = CLIArgumentType( + options_list=['--what-if'], + help="Preview the changes that will be made without actually executing the command. " + "This will call the what-if service to compare the current state with the expected state after execution." + ) + return what_if_type + + deployment_name_type = CLIArgumentType( help=argparse.SUPPRESS, required=False, diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py new file mode 100644 index 00000000000..7124c9573da --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -0,0 +1,109 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +""" +Module for handling what-if functionality in Azure CLI. +This module provides the core logic for preview mode execution without actually running commands. + +IMPORTANT: The what-if service requires client-side authentication to operate under the +caller's subscription and permissions. Server-side authentication is not supported for +what-if operations as it would not provide access to the caller's subscription. + +This client now uses AzureCliCredential to obtain an access token for the caller's subscription. + +The what-if service will use your configured credentials to access your subscription +and preview deployment changes under your permissions. +""" + +import requests +from typing import Dict, Any, Optional +from azure.identity import AzureCliCredential +from datetime import datetime, timezone +from knack.log import get_logger + +logger = get_logger(__name__) + +# Configuration +FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" + + +def get_azure_cli_access_token() -> Optional[str]: + """ + Get access token for the caller's subscription using AzureCliCredential + + Returns: + Access token string if successful, None if failed + """ + token_info = get_azure_cli_token_info() + return token_info.get("accessToken") if token_info else None + + +def get_azure_cli_token_info() -> Optional[Dict[str, Any]]: + """ + Get complete token information using AzureCliCredential including expiration + + Returns: + Dictionary with token info including accessToken, expiresOn, etc., or None if failed + """ + try: + # Use AzureCliCredential for Azure CLI authentication + cli_credential = AzureCliCredential(process_timeout=30) + + # Get access token for Azure Resource Manager + token = cli_credential.get_token("https://management.azure.com/.default") + + token_info = { + "accessToken": token.token, + "expiresOn": datetime.fromtimestamp(token.expires_on, tz=timezone.utc).isoformat(), + "tokenType": "Bearer" + } + + return token_info + + except Exception as e: + logger.warning(f"Error getting access token with AzureCliCredential: {str(e)}") + return None + + +def what_if_preview(azcli_script: str, subscription_id: Optional[str] = None) -> Dict[str, Any]: + """ + Preview deployment changes using Azure what-if functionality + + Args: + function_app_url: Base URL of your Azure Function App + azcli_script: Azure CLI script to analyze + subscription_id: Optional fallback subscription ID if not in script + + Returns: + Dictionary with what-if preview result + """ + url = f"{FUNCTION_APP_URL.rstrip('/')}/api/what_if_preview" + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + # Get access token from Azure CLI + access_token = get_azure_cli_access_token() + if not access_token: + return { + "error": "Failed to get access token from Azure CLI. Please ensure you are logged in with 'az login'", + "details": "The what-if service requires client credentials to access your subscription. Please provide an access token.", + "success": False + } + + # Use Authorization header for access token + headers['Authorization'] = f'Bearer {access_token}' + + payload = {"azcli_script": azcli_script} + if subscription_id: + payload["subscription_id"] = subscription_id + + try: + response = requests.post(url, json=payload, headers=headers, timeout=300) + return response.json() + except requests.RequestException as e: + raise e diff --git a/src/azure-cli/azure/cli/command_modules/sql/_params.py b/src/azure-cli/azure/cli/command_modules/sql/_params.py index b8ac4c6c5ec..4cc939be113 100644 --- a/src/azure-cli/azure/cli/command_modules/sql/_params.py +++ b/src/azure-cli/azure/cli/command_modules/sql/_params.py @@ -40,6 +40,7 @@ get_enum_type, get_resource_name_completion_list, get_location_type, + get_what_if_type, tags_type, resource_group_name_type ) @@ -1915,6 +1916,7 @@ def _configure_security_policy_storage_params(arg_ctx): with self.argument_context('sql server create') as c: c.argument('location', arg_type=get_location_type_with_default_from_resource_group(self.cli_ctx)) + c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.') # Create args that will be used to build up the Server object create_args_for_complex_type( diff --git a/src/azure-cli/azure/cli/command_modules/sql/custom.py b/src/azure-cli/azure/cli/command_modules/sql/custom.py index f4300c81ab7..aa965fba944 100644 --- a/src/azure-cli/azure/cli/command_modules/sql/custom.py +++ b/src/azure-cli/azure/cli/command_modules/sql/custom.py @@ -4369,6 +4369,7 @@ def server_create( external_admin_principal_type=None, external_admin_sid=None, external_admin_name=None, + what_if=None, **kwargs): ''' Creates a server. diff --git a/src/azure-cli/azure/cli/command_modules/vm/_params.py b/src/azure-cli/azure/cli/command_modules/vm/_params.py index b7f10a69afd..5aea79baf34 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/_params.py +++ b/src/azure-cli/azure/cli/command_modules/vm/_params.py @@ -13,7 +13,7 @@ from azure.cli.core.commands.validators import ( get_default_location_from_resource_group, validate_file_or_dict) from azure.cli.core.commands.parameters import ( - get_location_type, get_resource_name_completion_list, tags_type, get_three_state_flag, + get_location_type, get_what_if_type, get_resource_name_completion_list, tags_type, get_three_state_flag, file_type, get_enum_type, zone_type, zones_type) from azure.cli.command_modules.vm._actions import _resource_not_exists from azure.cli.command_modules.vm._completers import ( @@ -413,6 +413,7 @@ def load_arguments(self, _): c.argument('workspace', is_preview=True, arg_group='Monitor', help='Name or ID of Log Analytics Workspace. If you specify the workspace through its name, the workspace should be in the same resource group with the vm, otherwise a new workspace will be created.') with self.argument_context('vm update') as c: + c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.') c.argument('os_disk', min_api='2017-12-01', help="Managed OS disk ID or name to swap to") c.argument('write_accelerator', nargs='*', min_api='2017-12-01', help="enable/disable disk write accelerator. Use singular value 'true/false' to apply across, or specify individual disks, e.g.'os=true 1=true 2=true' for os disk and data disks with lun of 1 & 2") @@ -1062,6 +1063,7 @@ def load_arguments(self, _): for scope in ['vm create', 'vmss create']: with self.argument_context(scope) as c: c.argument('location', get_location_type(self.cli_ctx), help='Location in which to create VM and related resources. If default location is not configured, will default to the resource group\'s location') + c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.') c.argument('tags', tags_type) c.argument('no_wait', help='Do not wait for the long-running operation to finish.') c.argument('validate', options_list=['--validate'], help='Generate and validate the ARM template without creating any resources.', action='store_true') diff --git a/src/azure-cli/azure/cli/command_modules/vm/custom.py b/src/azure-cli/azure/cli/command_modules/vm/custom.py index 766fcada34a..75e34d965ad 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/custom.py +++ b/src/azure-cli/azure/cli/command_modules/vm/custom.py @@ -850,7 +850,7 @@ def create_vm(cmd, vm_name, resource_group_name, image=None, size='Standard_DS1_ enable_user_redeploy_scheduled_events=None, zone_placement_policy=None, include_zones=None, exclude_zones=None, align_regional_disks_to_vm_zone=None, wire_server_mode=None, imds_mode=None, wire_server_access_control_profile_reference_id=None, imds_access_control_profile_reference_id=None, - key_incarnation_id=None): + key_incarnation_id=None, what_if=False): from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import random_string, hash_string From df108e22cb1fc154ec7bdac830edd444b6007c17 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Wed, 17 Sep 2025 10:29:35 +0800 Subject: [PATCH 04/28] Update src/azure-cli-core/azure/cli/core/commands/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index f9e91b197f2..bdc1ef5a6ba 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -730,7 +730,7 @@ def _what_if(self, args): ) except Exception as ex: # If what-if service fails, still show an informative message - return CommandResultItem(None, exit_code=1, error=CLIError('What-if preview failed: {str(ex)}\nNote: This was a preview operation. No actual changes were made.')) + return CommandResultItem(None, exit_code=1, error=CLIError(f'What-if preview failed: {str(ex)}\nNote: This was a preview operation. No actual changes were made.')) @staticmethod def _extract_parameter_names(args): From f126b422c12934730b90d8a4ff898e8b6523a8e0 Mon Sep 17 00:00:00 2001 From: MoChilia Date: Thu, 18 Sep 2025 17:19:13 +0800 Subject: [PATCH 05/28] update --- .../azure/cli/command_modules/util/custom.py | 167 +++++++++++++++++- 1 file changed, 160 insertions(+), 7 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index ce4ab365e84..a2ada8a8883 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -398,12 +398,7 @@ def show_what_if(cmd, script_path): except Exception as ex: raise CLIError(f"Failed to connect to the what-if service: {ex}") - if response.status_code == 200: - try: - results = response.json() - except ValueError as ex: - raise CLIError(f"Failed to parse response from what-if service: {ex}") - else: + if response.status_code != 200: error_msg = f"HTTP {response.status_code}: Request failed" try: error_detail = response.text @@ -413,4 +408,162 @@ def show_what_if(cmd, script_path): pass raise CLIError(error_msg) - return results + try: + raw_results = response.json() + except ValueError as ex: + raise CLIError(f"Failed to parse response from what-if service: {ex}") + + if not raw_results.get('success', True): + return raw_results + + what_if_result = raw_results.get('what_if_result', {}) + changes = what_if_result.get('changes', []) + + from azure.cli.core.style import Style, print_styled_text + + print_styled_text([ + (Style.HIGHLIGHT, "═" * 80), + (Style.HIGHLIGHT, "\n"), + (Style.ACTION, " AZURE WHAT-IF ANALYSIS RESULTS\n"), + (Style.HIGHLIGHT, "═" * 80), + (Style.HIGHLIGHT, "\n\n") + ]) + + summary = what_if_result.get('summary', {}) + status = what_if_result.get('status', 'Unknown') + + print_styled_text([ + (Style.SUCCESS if status == 'Succeeded' else Style.WARNING, f"Status: {status}\n"), + (Style.PRIMARY, f"Total Changes: {len(changes)}\n\n") + ]) + + if summary: + print_styled_text([(Style.IMPORTANT, "Summary:\n")]) + for change_type, count in summary.items(): + color = Style.SUCCESS if change_type == 'Create' else Style.WARNING if change_type == 'Modify' else Style.ERROR + print_styled_text([(Style.PRIMARY, f" • "), (color, f"{change_type}: {count}\n")]) + print_styled_text("\n") + + for i, change in enumerate(changes, 1): + change_type = change.get('changeType', 'Unknown') + resource_info = change.get('after') or change.get('before') or {} + + if change_type == 'Create': + change_color = Style.SUCCESS + symbol = "+" + elif change_type == 'Delete': + change_color = Style.ERROR + symbol = "-" + elif change_type in ['Modify', 'Update']: + change_color = Style.WARNING + symbol = "~" + else: + change_color = Style.SECONDARY + symbol = "?" + + print_styled_text([ + (Style.HIGHLIGHT, f"[{i:02d}] "), + (change_color, f"{symbol} {change_type.upper()}\n"), + (Style.SECONDARY, "─" * 60 + "\n") + ]) + + print_styled_text([ + (Style.PRIMARY, "Resource: "), + (Style.ACTION, f"{resource_info.get('name', 'N/A')}\n"), + (Style.PRIMARY, "Type: "), + (Style.SECONDARY, f"{resource_info.get('type', 'N/A')}\n"), + (Style.PRIMARY, "Location: "), + (Style.SECONDARY, f"{resource_info.get('location', 'N/A')}\n"), + (Style.PRIMARY, "Group: "), + (Style.SECONDARY, f"{resource_info.get('resourceGroup', 'N/A')}\n") + ]) + + if change_type in ['Modify', 'Update'] and change.get('before') and change.get('after'): + print_styled_text([ + (Style.HIGHLIGHT, "\nComparison:\n"), + (Style.SECONDARY, "┌─ BEFORE " + "─" * 25 + "┬─ AFTER " + "─" * 26 + "┐\n") + ]) + + before_props = change['before'].get('properties', {}) + after_props = change['after'].get('properties', {}) + + all_keys = set(before_props.keys()) | set(after_props.keys()) + + for key in sorted(all_keys)[:5]: + before_val = str(before_props.get(key, 'N/A'))[:30] + after_val = str(after_props.get(key, 'N/A'))[:30] + + if before_props.get(key) != after_props.get(key): + key_color = Style.WARNING + else: + key_color = Style.SECONDARY + + print_styled_text([ + (Style.SECONDARY, "│ "), + (key_color, f"{key:<10}: "), + (Style.SECONDARY, f"{before_val:<18} │ "), + (key_color, f"{key:<10}: "), + (Style.SECONDARY, f"{after_val:<18} │\n") + ]) + + print_styled_text([(Style.SECONDARY, "└" + "─" * 33 + "┴" + "─" * 33 + "┘\n")]) + + elif change_type == 'Create' and change.get('after'): + after_props = change['after'].get('properties', {}) + if after_props: + print_styled_text([(Style.HIGHLIGHT, "\nKey Properties:\n")]) + for key, value in list(after_props.items())[:5]: + if isinstance(value, (str, int, float, bool)): + print_styled_text([ + (Style.PRIMARY, f" {key}: "), + (Style.SECONDARY, f"{str(value)[:50]}\n") + ]) + + print_styled_text("\n") + + if not changes: + print_styled_text([ + (Style.SUCCESS, "✓ No changes detected!\n"), + (Style.SECONDARY, "Your script will not modify any existing resources.\n") + ]) + + print_styled_text([ + (Style.HIGHLIGHT, "═" * 80 + "\n"), + (Style.SECONDARY, "Analysis complete. Review the changes above before executing your script.\n"), + (Style.HIGHLIGHT, "═" * 80 + "\n") + ]) + + processed_changes = [] + for change in changes: + change_type = change.get('changeType', 'Unknown') + resource_id = change.get('resourceId', '') + resource_info = change.get('after') or change.get('before') or {} + + processed_change = { + 'changeType': change_type, + 'resourceId': resource_id, + 'resourceType': resource_info.get('type', ''), + 'resourceName': resource_info.get('name', ''), + 'location': resource_info.get('location', ''), + 'resourceGroup': resource_info.get('resourceGroup', ''), + 'apiVersion': resource_info.get('apiVersion', '') + } + if change.get('before'): + processed_change['before'] = { + 'exists': True, + 'properties': change['before'].get('properties', {}) + } + + if change.get('after'): + processed_change['after'] = { + 'exists': True, + 'properties': change['after'].get('properties', {}) + } + + processed_changes.append(processed_change) + + return { + 'status': what_if_result.get('status', 'Unknown'), + 'summary': what_if_result.get('summary', {}), + 'totalChanges': len(processed_changes) + } From 08cc6c153fe7c13f4ab95691b0935ee79df14ee7 Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Fri, 19 Sep 2025 16:48:32 +0800 Subject: [PATCH 06/28] add progress bar and support pretty output --- .../azure/cli/command_modules/util/_help.py | 4 +- .../azure/cli/command_modules/util/_params.py | 3 +- .../azure/cli/command_modules/util/custom.py | 249 ++++++------------ src/azure-cli/service_name.json | 5 + 4 files changed, 97 insertions(+), 164 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/util/_help.py b/src/azure-cli/azure/cli/command_modules/util/_help.py index 90e3bba5062..d8489d0eb61 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_help.py +++ b/src/azure-cli/azure/cli/command_modules/util/_help.py @@ -95,8 +95,10 @@ helps['what-if'] = """ type: command -short-summary: Creates a sandboxed what-if simulation of Azure CLI scripts to visualize infrastructure changes before execution. +short-summary: Create a sandboxed what-if simulation of Azure CLI scripts to visualize infrastructure changes before execution. examples: - name: Simulate a what-if scenario for a resource group deletion text: az what-if --script-path "/path/to/your/script.sh" +- name: Simulate a what-if scenario for a specific subscription + text: az what-if --script-path "/path/to/your/script.sh" --subscription "MySubscription" """ \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/util/_params.py b/src/azure-cli/azure/cli/command_modules/util/_params.py index 687b3aa9a71..2fa8b1cd722 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_params.py +++ b/src/azure-cli/azure/cli/command_modules/util/_params.py @@ -53,4 +53,5 @@ def load_arguments(self, _): c.argument('subscription_id', help="Subscription ID under which to list resource groups") with self.argument_context('what-if') as c: - c.argument('script_path', help="Path to a script file containing Azure CLI commands to be executed.") + c.argument('script_path', help="Specify the path to a script file containing Azure CLI commands to be executed.") + c.argument('no_pretty_print', help="Disable pretty-printing of the output.") \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index a2ada8a8883..e5bc1874660 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -371,8 +371,14 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument # Assume the access token expires in 1 year / 31536000 seconds return AccessToken(self.access_token, int(time.time()) + 31536000) -def show_what_if(cmd, script_path): - FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" +def show_what_if(cmd, script_path, no_pretty_print=False): + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.core.util import send_raw_request + import json + from azure.cli.command_modules.resource._formatters import format_what_if_operation_result + import threading + import time + import sys try: with open(script_path, 'r', encoding='utf-8') as f: @@ -382,188 +388,107 @@ def show_what_if(cmd, script_path): except Exception as ex: raise CLIError(f"Error reading script file: {ex}") - from azure.cli.core.commands.client_factory import get_subscription_id subscription_id = get_subscription_id(cmd.cli_ctx) - payload = { "azcli_script": script_content, "subscription_id": subscription_id } - from azure.cli.core.util import send_raw_request - import json + + request_completed = threading.Event() + + def rotating_progress(): + """Simulate a rotating progress indicator for long running operation. + """ + chars = ["|", "\\", "/", "-"] + idx = 0 + while not request_completed.is_set(): + sys.stderr.write(f"\r{chars[idx % len(chars)]} Running") + sys.stderr.flush() + idx += 1 + time.sleep(0.2) + sys.stderr.write("\r" + " " * 20 + "\r") + sys.stderr.flush() try: + progress_thread = threading.Thread(target=rotating_progress) + progress_thread.daemon = True + progress_thread.start() + + FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" response = send_raw_request(cmd.cli_ctx, "POST", f"{FUNCTION_APP_URL}/api/what_if_preview", body=json.dumps(payload), resource="https://management.azure.com") + request_completed.set() + sys.stderr.write("Analysis completed\n") + sys.stderr.flush() + except Exception as ex: + request_completed.set() raise CLIError(f"Failed to connect to the what-if service: {ex}") - if response.status_code != 200: - error_msg = f"HTTP {response.status_code}: Request failed" - try: - error_detail = response.text - if error_detail: - error_msg += f" - {error_detail}" - except Exception: - pass - raise CLIError(error_msg) - try: raw_results = response.json() except ValueError as ex: raise CLIError(f"Failed to parse response from what-if service: {ex}") - if not raw_results.get('success', True): - return raw_results - what_if_result = raw_results.get('what_if_result', {}) - changes = what_if_result.get('changes', []) + what_if_operation_result = _convert_json_to_what_if_result(what_if_result) + + if no_pretty_print: + return what_if_result + + print(format_what_if_operation_result(what_if_operation_result, cmd.cli_ctx.enable_color)) + return what_if_result + + +def _convert_json_to_what_if_result(what_if_json_result): + from azure.cli.command_modules.resource._formatters import _change_type_to_weight + enum_keys = list(_change_type_to_weight.keys()) + enum_mapping = {} + for enum_obj in enum_keys: + str_repr = str(enum_obj).lower() + if 'create' in str_repr: + enum_mapping['Create'] = enum_obj + elif 'delete' in str_repr: + enum_mapping['Delete'] = enum_obj + elif 'modify' in str_repr: + enum_mapping['Modify'] = enum_obj + elif 'deploy' in str_repr: + enum_mapping['Deploy'] = enum_obj + elif 'no_change' in str_repr or 'nochange' in str_repr: + enum_mapping['NoChange'] = enum_obj + elif 'ignore' in str_repr: + enum_mapping['Ignore'] = enum_obj + elif 'unsupported' in str_repr: + enum_mapping['Unsupported'] = enum_obj - from azure.cli.core.style import Style, print_styled_text + class WhatIfOperationResult: + def __init__(self): + self.changes = [] + self.potential_changes = [] + self.diagnostics = [] - print_styled_text([ - (Style.HIGHLIGHT, "═" * 80), - (Style.HIGHLIGHT, "\n"), - (Style.ACTION, " AZURE WHAT-IF ANALYSIS RESULTS\n"), - (Style.HIGHLIGHT, "═" * 80), - (Style.HIGHLIGHT, "\n\n") - ]) - - summary = what_if_result.get('summary', {}) - status = what_if_result.get('status', 'Unknown') + class ResourceChange: + def __init__(self, change_data): + self.change_type = _map_change_type_string(change_data.get('changeType', 'Unknown')) + self.resource_id = change_data.get('resourceId', '') + self.before = change_data.get('before') + self.after = change_data.get('after') + self.delta = change_data.get('delta') - print_styled_text([ - (Style.SUCCESS if status == 'Succeeded' else Style.WARNING, f"Status: {status}\n"), - (Style.PRIMARY, f"Total Changes: {len(changes)}\n\n") - ]) + def _map_change_type_string(change_type_str): + result = enum_mapping.get(change_type_str) + return result - if summary: - print_styled_text([(Style.IMPORTANT, "Summary:\n")]) - for change_type, count in summary.items(): - color = Style.SUCCESS if change_type == 'Create' else Style.WARNING if change_type == 'Modify' else Style.ERROR - print_styled_text([(Style.PRIMARY, f" • "), (color, f"{change_type}: {count}\n")]) - print_styled_text("\n") + result = WhatIfOperationResult() - for i, change in enumerate(changes, 1): - change_type = change.get('changeType', 'Unknown') - resource_info = change.get('after') or change.get('before') or {} - - if change_type == 'Create': - change_color = Style.SUCCESS - symbol = "+" - elif change_type == 'Delete': - change_color = Style.ERROR - symbol = "-" - elif change_type in ['Modify', 'Update']: - change_color = Style.WARNING - symbol = "~" - else: - change_color = Style.SECONDARY - symbol = "?" - - print_styled_text([ - (Style.HIGHLIGHT, f"[{i:02d}] "), - (change_color, f"{symbol} {change_type.upper()}\n"), - (Style.SECONDARY, "─" * 60 + "\n") - ]) - - print_styled_text([ - (Style.PRIMARY, "Resource: "), - (Style.ACTION, f"{resource_info.get('name', 'N/A')}\n"), - (Style.PRIMARY, "Type: "), - (Style.SECONDARY, f"{resource_info.get('type', 'N/A')}\n"), - (Style.PRIMARY, "Location: "), - (Style.SECONDARY, f"{resource_info.get('location', 'N/A')}\n"), - (Style.PRIMARY, "Group: "), - (Style.SECONDARY, f"{resource_info.get('resourceGroup', 'N/A')}\n") - ]) - - if change_type in ['Modify', 'Update'] and change.get('before') and change.get('after'): - print_styled_text([ - (Style.HIGHLIGHT, "\nComparison:\n"), - (Style.SECONDARY, "┌─ BEFORE " + "─" * 25 + "┬─ AFTER " + "─" * 26 + "┐\n") - ]) - - before_props = change['before'].get('properties', {}) - after_props = change['after'].get('properties', {}) - - all_keys = set(before_props.keys()) | set(after_props.keys()) - - for key in sorted(all_keys)[:5]: - before_val = str(before_props.get(key, 'N/A'))[:30] - after_val = str(after_props.get(key, 'N/A'))[:30] - - if before_props.get(key) != after_props.get(key): - key_color = Style.WARNING - else: - key_color = Style.SECONDARY - - print_styled_text([ - (Style.SECONDARY, "│ "), - (key_color, f"{key:<10}: "), - (Style.SECONDARY, f"{before_val:<18} │ "), - (key_color, f"{key:<10}: "), - (Style.SECONDARY, f"{after_val:<18} │\n") - ]) - - print_styled_text([(Style.SECONDARY, "└" + "─" * 33 + "┴" + "─" * 33 + "┘\n")]) - - elif change_type == 'Create' and change.get('after'): - after_props = change['after'].get('properties', {}) - if after_props: - print_styled_text([(Style.HIGHLIGHT, "\nKey Properties:\n")]) - for key, value in list(after_props.items())[:5]: - if isinstance(value, (str, int, float, bool)): - print_styled_text([ - (Style.PRIMARY, f" {key}: "), - (Style.SECONDARY, f"{str(value)[:50]}\n") - ]) - - print_styled_text("\n") - - if not changes: - print_styled_text([ - (Style.SUCCESS, "✓ No changes detected!\n"), - (Style.SECONDARY, "Your script will not modify any existing resources.\n") - ]) + changes = what_if_json_result.get('changes', []) + for change_data in changes: + resource_change = ResourceChange(change_data) + result.changes.append(resource_change) - print_styled_text([ - (Style.HIGHLIGHT, "═" * 80 + "\n"), - (Style.SECONDARY, "Analysis complete. Review the changes above before executing your script.\n"), - (Style.HIGHLIGHT, "═" * 80 + "\n") - ]) + potential_changes = what_if_json_result.get('potential_changes', []) + for change_data in potential_changes: + resource_change = ResourceChange(change_data) + result.potential_changes.append(resource_change) - processed_changes = [] - for change in changes: - change_type = change.get('changeType', 'Unknown') - resource_id = change.get('resourceId', '') - resource_info = change.get('after') or change.get('before') or {} - - processed_change = { - 'changeType': change_type, - 'resourceId': resource_id, - 'resourceType': resource_info.get('type', ''), - 'resourceName': resource_info.get('name', ''), - 'location': resource_info.get('location', ''), - 'resourceGroup': resource_info.get('resourceGroup', ''), - 'apiVersion': resource_info.get('apiVersion', '') - } - if change.get('before'): - processed_change['before'] = { - 'exists': True, - 'properties': change['before'].get('properties', {}) - } - - if change.get('after'): - processed_change['after'] = { - 'exists': True, - 'properties': change['after'].get('properties', {}) - } - - processed_changes.append(processed_change) - - return { - 'status': what_if_result.get('status', 'Unknown'), - 'summary': what_if_result.get('summary', {}), - 'totalChanges': len(processed_changes) - } + return result diff --git a/src/azure-cli/service_name.json b/src/azure-cli/service_name.json index e260e066dc8..e8a059e7a2c 100644 --- a/src/azure-cli/service_name.json +++ b/src/azure-cli/service_name.json @@ -548,5 +548,10 @@ "Command": "az webapp", "AzureServiceName": "App Services", "URL": "https://learn.microsoft.com/rest/api/appservice/webapps" + }, + { + "Command": "az what-if", + "AzureServiceName": "Azure CLI", + "URL": "" } ] From 6a1b839c6fa95c7e6f892607c259e56f868de985 Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Fri, 19 Sep 2025 17:22:47 +0800 Subject: [PATCH 07/28] fix style issue --- .../azure/cli/command_modules/util/custom.py | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index e5bc1874660..4aac50521f8 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -371,6 +371,7 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument # Assume the access token expires in 1 year / 31536000 seconds return AccessToken(self.access_token, int(time.time()) + 31536000) + def show_what_if(cmd, script_path, no_pretty_print=False): from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import send_raw_request @@ -379,7 +380,7 @@ def show_what_if(cmd, script_path, no_pretty_print=False): import threading import time import sys - + try: with open(script_path, 'r', encoding='utf-8') as f: script_content = f.read() @@ -387,7 +388,7 @@ def show_what_if(cmd, script_path, no_pretty_print=False): raise CLIError(f"Script file not found: {script_path}") except Exception as ex: raise CLIError(f"Error reading script file: {ex}") - + subscription_id = get_subscription_id(cmd.cli_ctx) payload = { "azcli_script": script_content, @@ -395,7 +396,7 @@ def show_what_if(cmd, script_path, no_pretty_print=False): } request_completed = threading.Event() - + def rotating_progress(): """Simulate a rotating progress indicator for long running operation. """ @@ -408,28 +409,28 @@ def rotating_progress(): time.sleep(0.2) sys.stderr.write("\r" + " " * 20 + "\r") sys.stderr.flush() - + try: progress_thread = threading.Thread(target=rotating_progress) progress_thread.daemon = True progress_thread.start() - + FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" - response = send_raw_request(cmd.cli_ctx, "POST", f"{FUNCTION_APP_URL}/api/what_if_preview", - body=json.dumps(payload), resource="https://management.azure.com") + response = send_raw_request(cmd.cli_ctx, "POST", f"{FUNCTION_APP_URL}/api/what_if_preview", + body=json.dumps(payload), resource="https://management.azure.com") request_completed.set() sys.stderr.write("Analysis completed\n") sys.stderr.flush() - + except Exception as ex: request_completed.set() raise CLIError(f"Failed to connect to the what-if service: {ex}") - + try: raw_results = response.json() except ValueError as ex: raise CLIError(f"Failed to parse response from what-if service: {ex}") - + what_if_result = raw_results.get('what_if_result', {}) what_if_operation_result = _convert_json_to_what_if_result(what_if_result) @@ -460,13 +461,13 @@ def _convert_json_to_what_if_result(what_if_json_result): enum_mapping['Ignore'] = enum_obj elif 'unsupported' in str_repr: enum_mapping['Unsupported'] = enum_obj - + class WhatIfOperationResult: def __init__(self): self.changes = [] self.potential_changes = [] self.diagnostics = [] - + class ResourceChange: def __init__(self, change_data): self.change_type = _map_change_type_string(change_data.get('changeType', 'Unknown')) @@ -474,21 +475,21 @@ def __init__(self, change_data): self.before = change_data.get('before') self.after = change_data.get('after') self.delta = change_data.get('delta') - + def _map_change_type_string(change_type_str): result = enum_mapping.get(change_type_str) return result - + result = WhatIfOperationResult() - + changes = what_if_json_result.get('changes', []) for change_data in changes: resource_change = ResourceChange(change_data) result.changes.append(resource_change) - + potential_changes = what_if_json_result.get('potential_changes', []) for change_data in potential_changes: resource_change = ResourceChange(change_data) result.potential_changes.append(resource_change) - + return result From 6a52aacbe66c7f15bbb464784eb5826ce5341e2e Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Fri, 19 Sep 2025 17:45:12 +0800 Subject: [PATCH 08/28] add a mock test --- .../azure/cli/command_modules/util/_help.py | 4 +- .../util/tests/latest/test_whatif.py | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py diff --git a/src/azure-cli/azure/cli/command_modules/util/_help.py b/src/azure-cli/azure/cli/command_modules/util/_help.py index d8489d0eb61..d125fa69a63 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_help.py +++ b/src/azure-cli/azure/cli/command_modules/util/_help.py @@ -97,8 +97,8 @@ type: command short-summary: Create a sandboxed what-if simulation of Azure CLI scripts to visualize infrastructure changes before execution. examples: -- name: Simulate a what-if scenario for a resource group deletion +- name: Simulate a what-if scenario for a provided script text: az what-if --script-path "/path/to/your/script.sh" - name: Simulate a what-if scenario for a specific subscription - text: az what-if --script-path "/path/to/your/script.sh" --subscription "MySubscription" + text: az what-if --script-path "/path/to/your/script.sh" --subscription 00000000-0000-0000-0000-000000000000 """ \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py new file mode 100644 index 00000000000..4c90713439f --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py @@ -0,0 +1,52 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +import os +from unittest.mock import patch, Mock +from azure.cli.testsdk import ScenarioTest + +TEST_DIR = os.path.dirname(os.path.realpath(__file__)) + + +class WhatIfTest(ScenarioTest): + + def setUp(self): + super().setUp() + self.test_script_path = os.path.join(TEST_DIR, 'test_whatif_script.sh') + + @patch('azure.cli.core.util.send_raw_request') + def test_what_if_command_success(self, mock_send_raw_request): + mock_response = Mock() + mock_response.json.return_value = { + "what_if_result": { + "changes": [ + { + "changeType": "Create", + "resourceId": "/subscriptions/test/resourceGroups/myrg/providers/Microsoft.Compute/virtualMachines/MyVM_01", + "before": None, + "after": { + "name": "MyVM_01", + "type": "Microsoft.Compute/virtualMachines", + "location": "eastus" + } + } + ], + "potential_changes": [], + "diagnostics": [] + } + } + mock_send_raw_request.return_value = mock_response + result = self.cmd('az what-if --script-path "{}" --no-pretty-print'.format(self.test_script_path)) + output = result.get_output_in_json() + self.assertIsInstance(output, dict) + self.assertIn("changes", output) + self.assertEqual(len(output["changes"]), 1) + self.assertEqual(output["changes"][0]["changeType"], "Create") + mock_send_raw_request.assert_called_once() + + +if __name__ == '__main__': + unittest.main() From b4aae27cea45cf901d044b6a844c0125fc04a423 Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Fri, 19 Sep 2025 18:39:58 +0800 Subject: [PATCH 09/28] use get_raw_token --- .../azure/cli/command_modules/util/_params.py | 4 +-- .../azure/cli/command_modules/util/custom.py | 31 ++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/util/_params.py b/src/azure-cli/azure/cli/command_modules/util/_params.py index 2fa8b1cd722..8c6930253d4 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_params.py +++ b/src/azure-cli/azure/cli/command_modules/util/_params.py @@ -51,7 +51,7 @@ def load_arguments(self, _): with self.argument_context('demo byo-access-token') as c: c.argument('access_token', help="Your own access token") c.argument('subscription_id', help="Subscription ID under which to list resource groups") - + with self.argument_context('what-if') as c: c.argument('script_path', help="Specify the path to a script file containing Azure CLI commands to be executed.") - c.argument('no_pretty_print', help="Disable pretty-printing of the output.") \ No newline at end of file + c.argument('no_pretty_print', help="Disable pretty-printing of the output.") diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index 4aac50521f8..6f7b0dbb7d3 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -375,11 +375,12 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument def show_what_if(cmd, script_path, no_pretty_print=False): from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import send_raw_request - import json from azure.cli.command_modules.resource._formatters import format_what_if_operation_result + from azure.cli.core._profile import Profile import threading import time import sys + import json try: with open(script_path, 'r', encoding='utf-8') as f: @@ -398,7 +399,7 @@ def show_what_if(cmd, script_path, no_pretty_print=False): request_completed = threading.Event() def rotating_progress(): - """Simulate a rotating progress indicator for long running operation. + """Simulate a rotating progress indicator, similar to the one displayed during long-running operations. """ chars = ["|", "\\", "/", "-"] idx = 0 @@ -407,20 +408,34 @@ def rotating_progress(): sys.stderr.flush() idx += 1 time.sleep(0.2) - sys.stderr.write("\r" + " " * 20 + "\r") + sys.stderr.write("\r" + " " * 50 + "\r") sys.stderr.flush() try: + FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" + resource = cmd.cli_ctx.cloud.endpoints.active_directory_resource_id + profile = Profile(cli_ctx=cmd.cli_ctx) + + try: + token_result = profile.get_raw_token(resource, subscription=subscription_id) + token_info, _, _ = token_result + token_type, token, _ = token_info + except Exception as token_ex: + request_completed.set() + raise CLIError(f"Failed to get authentication token: {token_ex}") + + headers = [ + 'Authorization={} {}'.format(token_type, token), + 'Content-Type=application/json' + ] + progress_thread = threading.Thread(target=rotating_progress) progress_thread.daemon = True progress_thread.start() - FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" - response = send_raw_request(cmd.cli_ctx, "POST", f"{FUNCTION_APP_URL}/api/what_if_preview", - body=json.dumps(payload), resource="https://management.azure.com") + response = send_raw_request(cmd.cli_ctx, "POST", f"{FUNCTION_APP_URL}/api/what_if_preview", headers=headers, + body=json.dumps(payload)) request_completed.set() - sys.stderr.write("Analysis completed\n") - sys.stderr.flush() except Exception as ex: request_completed.set() From b764741ec7b98cf5fcf4c6e9f1a756ae7dffa3ee Mon Sep 17 00:00:00 2001 From: MoChilia Date: Mon, 22 Sep 2025 13:14:42 +0800 Subject: [PATCH 10/28] def PropertyChange --- .../azure/cli/command_modules/util/_help.py | 2 +- .../azure/cli/command_modules/util/custom.py | 68 ++++++++++++++++--- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/util/_help.py b/src/azure-cli/azure/cli/command_modules/util/_help.py index d125fa69a63..241abcef88d 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_help.py +++ b/src/azure-cli/azure/cli/command_modules/util/_help.py @@ -101,4 +101,4 @@ text: az what-if --script-path "/path/to/your/script.sh" - name: Simulate a what-if scenario for a specific subscription text: az what-if --script-path "/path/to/your/script.sh" --subscription 00000000-0000-0000-0000-000000000000 -""" \ No newline at end of file +""" diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index 6f7b0dbb7d3..6a669567bbf 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -374,13 +374,13 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument def show_what_if(cmd, script_path, no_pretty_print=False): from azure.cli.core.commands.client_factory import get_subscription_id - from azure.cli.core.util import send_raw_request from azure.cli.command_modules.resource._formatters import format_what_if_operation_result from azure.cli.core._profile import Profile import threading import time import sys import json + from requests import Request, Session try: with open(script_path, 'r', encoding='utf-8') as f: @@ -408,7 +408,7 @@ def rotating_progress(): sys.stderr.flush() idx += 1 time.sleep(0.2) - sys.stderr.write("\r" + " " * 50 + "\r") + sys.stderr.write("\r" + " " * 20 + "\r") sys.stderr.flush() try: @@ -424,21 +424,27 @@ def rotating_progress(): request_completed.set() raise CLIError(f"Failed to get authentication token: {token_ex}") - headers = [ - 'Authorization={} {}'.format(token_type, token), - 'Content-Type=application/json' - ] + headers_dict = {} + headers_dict['Authorization'] = '{} {}'.format(token_type, token) + headers_dict['Content-Type'] = 'application/json' progress_thread = threading.Thread(target=rotating_progress) progress_thread.daemon = True progress_thread.start() - response = send_raw_request(cmd.cli_ctx, "POST", f"{FUNCTION_APP_URL}/api/what_if_preview", headers=headers, - body=json.dumps(payload)) + session = Session() + req = Request(method="POST", url=f"{FUNCTION_APP_URL}/api/what_if_preview", + headers=headers_dict, data=json.dumps(payload)) + prepared = session.prepare_request(req) + response = session.send(prepared) request_completed.set() + progress_thread.join(timeout=0.5) + except Exception as ex: request_completed.set() + if 'progress_thread' in locals(): + progress_thread.join(timeout=0.5) raise CLIError(f"Failed to connect to the what-if service: {ex}") try: @@ -457,7 +463,8 @@ def rotating_progress(): def _convert_json_to_what_if_result(what_if_json_result): - from azure.cli.command_modules.resource._formatters import _change_type_to_weight + from azure.cli.command_modules.resource._formatters import _change_type_to_weight, _property_change_type_to_weight + enum_keys = list(_change_type_to_weight.keys()) enum_mapping = {} for enum_obj in enum_keys: @@ -476,6 +483,23 @@ def _convert_json_to_what_if_result(what_if_json_result): enum_mapping['Ignore'] = enum_obj elif 'unsupported' in str_repr: enum_mapping['Unsupported'] = enum_obj + elif 'no_effect' in str_repr or 'noeffect' in str_repr: + enum_mapping['NoEffect'] = enum_obj + + property_enum_keys = list(_property_change_type_to_weight.keys()) + property_enum_mapping = {} + for enum_obj in property_enum_keys: + str_repr = str(enum_obj).lower() + if 'create' in str_repr: + property_enum_mapping['Create'] = enum_obj + elif 'delete' in str_repr: + property_enum_mapping['Delete'] = enum_obj + elif 'modify' in str_repr: + property_enum_mapping['Modify'] = enum_obj + elif 'array' in str_repr: + property_enum_mapping['Array'] = enum_obj + elif 'no_effect' in str_repr or 'noeffect' in str_repr: + property_enum_mapping['NoEffect'] = enum_obj class WhatIfOperationResult: def __init__(self): @@ -489,11 +513,35 @@ def __init__(self, change_data): self.resource_id = change_data.get('resourceId', '') self.before = change_data.get('before') self.after = change_data.get('after') - self.delta = change_data.get('delta') + self.delta = [] + + delta_data = change_data.get('delta', []) + for property_data in delta_data: + property_change = PropertyChange(property_data) + self.delta.append(property_change) + + class PropertyChange: + def __init__(self, change_data): + self.property_change_type = _map_property_change_type_string(change_data.get('propertyChangeType', 'NoEffect')) + self.path = change_data.get('path', '') + self.before = change_data.get('before') + self.after = change_data.get('after') + self.children = [] + + children_data = change_data.get('children', []) + for child_data in children_data: + child_property_change = PropertyChange(child_data) + self.children.append(child_property_change) + def _map_change_type_string(change_type_str): result = enum_mapping.get(change_type_str) return result + + + def _map_property_change_type_string(property_change_type_str): + result = property_enum_mapping.get(property_change_type_str) + return result result = WhatIfOperationResult() From c3caf10b7dc08a9cf4959698153b3753af5bb1bb Mon Sep 17 00:00:00 2001 From: MoChilia Date: Mon, 22 Sep 2025 14:03:07 +0800 Subject: [PATCH 11/28] update mock test --- .../azure/cli/command_modules/util/custom.py | 13 ++++++------- .../util/tests/latest/test_whatif.py | 11 ++++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index 6a669567bbf..2696ab2b71d 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -433,8 +433,8 @@ def rotating_progress(): progress_thread.start() session = Session() - req = Request(method="POST", url=f"{FUNCTION_APP_URL}/api/what_if_preview", - headers=headers_dict, data=json.dumps(payload)) + req = Request(method="POST", url=f"{FUNCTION_APP_URL}/api/what_if_preview", + headers=headers_dict, data=json.dumps(payload)) prepared = session.prepare_request(req) response = session.send(prepared) request_completed.set() @@ -464,7 +464,7 @@ def rotating_progress(): def _convert_json_to_what_if_result(what_if_json_result): from azure.cli.command_modules.resource._formatters import _change_type_to_weight, _property_change_type_to_weight - + enum_keys = list(_change_type_to_weight.keys()) enum_mapping = {} for enum_obj in enum_keys: @@ -519,10 +519,11 @@ def __init__(self, change_data): for property_data in delta_data: property_change = PropertyChange(property_data) self.delta.append(property_change) - + class PropertyChange: def __init__(self, change_data): - self.property_change_type = _map_property_change_type_string(change_data.get('propertyChangeType', 'NoEffect')) + self.property_change_type = _map_property_change_type_string( + change_data.get('propertyChangeType', 'NoEffect')) self.path = change_data.get('path', '') self.before = change_data.get('before') self.after = change_data.get('after') @@ -533,11 +534,9 @@ def __init__(self, change_data): child_property_change = PropertyChange(child_data) self.children.append(child_property_change) - def _map_change_type_string(change_type_str): result = enum_mapping.get(change_type_str) return result - def _map_property_change_type_string(property_change_type_str): result = property_enum_mapping.get(property_change_type_str) diff --git a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py index 4c90713439f..f3b955d4a7c 100644 --- a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py +++ b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py @@ -17,8 +17,8 @@ def setUp(self): super().setUp() self.test_script_path = os.path.join(TEST_DIR, 'test_whatif_script.sh') - @patch('azure.cli.core.util.send_raw_request') - def test_what_if_command_success(self, mock_send_raw_request): + @patch('requests.Session.send') + def test_what_if_command(self, mock_session_send): mock_response = Mock() mock_response.json.return_value = { "what_if_result": { @@ -31,21 +31,22 @@ def test_what_if_command_success(self, mock_send_raw_request): "name": "MyVM_01", "type": "Microsoft.Compute/virtualMachines", "location": "eastus" - } + }, + "delta": [] } ], "potential_changes": [], "diagnostics": [] } } - mock_send_raw_request.return_value = mock_response + mock_session_send.return_value = mock_response result = self.cmd('az what-if --script-path "{}" --no-pretty-print'.format(self.test_script_path)) output = result.get_output_in_json() self.assertIsInstance(output, dict) self.assertIn("changes", output) self.assertEqual(len(output["changes"]), 1) self.assertEqual(output["changes"][0]["changeType"], "Create") - mock_send_raw_request.assert_called_once() + mock_session_send.assert_called_once() if __name__ == '__main__': From 2b61732dc16720de971d679ac69e38305b32507f Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Mon, 22 Sep 2025 14:35:04 +0800 Subject: [PATCH 12/28] minor fix --- .../azure/cli/core/commands/__init__.py | 9 +- .../azure/cli/core/commands/parameters.py | 3 +- src/azure-cli-core/azure/cli/core/what_if.py | 250 ++++++++++++------ .../azure/cli/command_modules/sql/_params.py | 2 +- .../azure/cli/command_modules/vm/_params.py | 4 +- 5 files changed, 181 insertions(+), 87 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index bdc1ef5a6ba..c5a13b9b0b1 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -699,7 +699,7 @@ def _what_if(self, args): print(f"DEBUG: _what_if called with command: {args}") if '--what-if' in args: print("DEBUG: Entering what-if mode") - from azure.cli.core.what_if import what_if_preview + from azure.cli.core.what_if import show_what_if try: # Get subscription ID with priority: --subscription parameter > current login subscription if '--subscription' in args: @@ -708,12 +708,13 @@ def _what_if(self, args): subscription_value = args[index + 1] subscription_id = subscription_value else: - # Fallback to current login subscription TODO - subscription_id = self.cli_ctx.data.get("subscription_id", "6b085460-5f21-477e-ba44-1035046e9101") + from azure.cli.core.commands.client_factory import get_subscription_id + subscription_id = get_subscription_id(self.cli_ctx) + print(f"DEBUG: Using current login subscription ID: {subscription_id}") args = ["az"] + args if args[0] != 'az' else args command = " ".join(args) - what_if_result = what_if_preview(command, subscription_id=subscription_id) + what_if_result = show_what_if(self.cli_ctx, command, subscription_id=subscription_id) # Ensure output format is set for proper formatting # Default to 'json' if not already set diff --git a/src/azure-cli-core/azure/cli/core/commands/parameters.py b/src/azure-cli-core/azure/cli/core/commands/parameters.py index 0905238cb71..c165dac37ba 100644 --- a/src/azure-cli-core/azure/cli/core/commands/parameters.py +++ b/src/azure-cli-core/azure/cli/core/commands/parameters.py @@ -272,7 +272,8 @@ def get_what_if_type(): what_if_type = CLIArgumentType( options_list=['--what-if'], help="Preview the changes that will be made without actually executing the command. " - "This will call the what-if service to compare the current state with the expected state after execution." + "This will call the what-if service to compare the current state with the expected state after execution.", + is_preview=True ) return what_if_type diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py index 7124c9573da..8fe9b01fd74 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -16,94 +16,186 @@ The what-if service will use your configured credentials to access your subscription and preview deployment changes under your permissions. """ - -import requests from typing import Dict, Any, Optional -from azure.identity import AzureCliCredential -from datetime import datetime, timezone from knack.log import get_logger logger = get_logger(__name__) -# Configuration -FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" - -def get_azure_cli_access_token() -> Optional[str]: - """ - Get access token for the caller's subscription using AzureCliCredential +def show_what_if(cli_ctx, azcli_script: str, subscription_id: Optional[str] = None, no_pretty_print: bool = False): + from azure.cli.command_modules.resource._formatters import format_what_if_operation_result + from azure.cli.core._profile import Profile + import threading + import time + import sys + import json + from requests import Request, Session - Returns: - Access token string if successful, None if failed - """ - token_info = get_azure_cli_token_info() - return token_info.get("accessToken") if token_info else None + payload = { + "azcli_script": azcli_script, + "subscription_id": subscription_id + } + request_completed = threading.Event() + + def rotating_progress(): + """Simulate a rotating progress indicator, similar to the one displayed during long-running operations. + """ + chars = ["|", "\\", "/", "-"] + idx = 0 + while not request_completed.is_set(): + sys.stderr.write(f"\r{chars[idx % len(chars)]} Running") + sys.stderr.flush() + idx += 1 + time.sleep(0.2) + sys.stderr.write("\r" + " " * 20 + "\r") + sys.stderr.flush() -def get_azure_cli_token_info() -> Optional[Dict[str, Any]]: - """ - Get complete token information using AzureCliCredential including expiration - - Returns: - Dictionary with token info including accessToken, expiresOn, etc., or None if failed - """ try: - # Use AzureCliCredential for Azure CLI authentication - cli_credential = AzureCliCredential(process_timeout=30) - - # Get access token for Azure Resource Manager - token = cli_credential.get_token("https://management.azure.com/.default") - - token_info = { - "accessToken": token.token, - "expiresOn": datetime.fromtimestamp(token.expires_on, tz=timezone.utc).isoformat(), - "tokenType": "Bearer" - } - - return token_info - - except Exception as e: - logger.warning(f"Error getting access token with AzureCliCredential: {str(e)}") - return None + FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" + resource = cli_ctx.cloud.endpoints.active_directory_resource_id + profile = Profile(cli_ctx=cli_ctx) + + try: + token_result = profile.get_raw_token(resource, subscription=subscription_id) + token_info, _, _ = token_result + token_type, token, _ = token_info + except Exception as token_ex: + request_completed.set() + raise CLIError(f"Failed to get authentication token: {token_ex}") + + headers_dict = {} + headers_dict['Authorization'] = '{} {}'.format(token_type, token) + headers_dict['Content-Type'] = 'application/json' + + progress_thread = threading.Thread(target=rotating_progress) + progress_thread.daemon = True + progress_thread.start() + + session = Session() + req = Request(method="POST", url=f"{FUNCTION_APP_URL}/api/what_if_preview", + headers=headers_dict, data=json.dumps(payload)) + prepared = session.prepare_request(req) + response = session.send(prepared) + request_completed.set() + + progress_thread.join(timeout=0.5) + + except Exception as ex: + request_completed.set() + if 'progress_thread' in locals(): + progress_thread.join(timeout=0.5) + raise CLIError(f"Failed to connect to the what-if service: {ex}") - -def what_if_preview(azcli_script: str, subscription_id: Optional[str] = None) -> Dict[str, Any]: - """ - Preview deployment changes using Azure what-if functionality - - Args: - function_app_url: Base URL of your Azure Function App - azcli_script: Azure CLI script to analyze - subscription_id: Optional fallback subscription ID if not in script - - Returns: - Dictionary with what-if preview result - """ - url = f"{FUNCTION_APP_URL.rstrip('/')}/api/what_if_preview" - - headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - - # Get access token from Azure CLI - access_token = get_azure_cli_access_token() - if not access_token: - return { - "error": "Failed to get access token from Azure CLI. Please ensure you are logged in with 'az login'", - "details": "The what-if service requires client credentials to access your subscription. Please provide an access token.", - "success": False - } - - # Use Authorization header for access token - headers['Authorization'] = f'Bearer {access_token}' - - payload = {"azcli_script": azcli_script} - if subscription_id: - payload["subscription_id"] = subscription_id - try: - response = requests.post(url, json=payload, headers=headers, timeout=300) - return response.json() - except requests.RequestException as e: - raise e + raw_results = response.json() + except ValueError as ex: + raise CLIError(f"Failed to parse response from what-if service: {ex}") + + success = raw_results.get('success') + if success is False: + return raw_results + elif success is True: + what_if_result = raw_results.get('what_if_result', {}) + what_if_operation_result = _convert_json_to_what_if_result(what_if_result) + if no_pretty_print: + return what_if_result + print(format_what_if_operation_result(what_if_operation_result, cli_ctx.enable_color)) + return what_if_result + else: + raise CLIError(f"Unexpected response from what-if service, got: {raw_results}") + + +def _convert_json_to_what_if_result(what_if_json_result): + from azure.cli.command_modules.resource._formatters import _change_type_to_weight, _property_change_type_to_weight + + enum_keys = list(_change_type_to_weight.keys()) + enum_mapping = {} + for enum_obj in enum_keys: + str_repr = str(enum_obj).lower() + if 'create' in str_repr: + enum_mapping['Create'] = enum_obj + elif 'delete' in str_repr: + enum_mapping['Delete'] = enum_obj + elif 'modify' in str_repr: + enum_mapping['Modify'] = enum_obj + elif 'deploy' in str_repr: + enum_mapping['Deploy'] = enum_obj + elif 'no_change' in str_repr or 'nochange' in str_repr: + enum_mapping['NoChange'] = enum_obj + elif 'ignore' in str_repr: + enum_mapping['Ignore'] = enum_obj + elif 'unsupported' in str_repr: + enum_mapping['Unsupported'] = enum_obj + elif 'no_effect' in str_repr or 'noeffect' in str_repr: + enum_mapping['NoEffect'] = enum_obj + + property_enum_keys = list(_property_change_type_to_weight.keys()) + property_enum_mapping = {} + for enum_obj in property_enum_keys: + str_repr = str(enum_obj).lower() + if 'create' in str_repr: + property_enum_mapping['Create'] = enum_obj + elif 'delete' in str_repr: + property_enum_mapping['Delete'] = enum_obj + elif 'modify' in str_repr: + property_enum_mapping['Modify'] = enum_obj + elif 'array' in str_repr: + property_enum_mapping['Array'] = enum_obj + elif 'no_effect' in str_repr or 'noeffect' in str_repr: + property_enum_mapping['NoEffect'] = enum_obj + + class WhatIfOperationResult: + def __init__(self): + self.changes = [] + self.potential_changes = [] + self.diagnostics = [] + + class ResourceChange: + def __init__(self, change_data): + self.change_type = _map_change_type_string(change_data.get('changeType', 'Unknown')) + self.resource_id = change_data.get('resourceId', '') + self.before = change_data.get('before') + self.after = change_data.get('after') + self.delta = [] + + delta_data = change_data.get('delta', []) + for property_data in delta_data: + property_change = PropertyChange(property_data) + self.delta.append(property_change) + + class PropertyChange: + def __init__(self, change_data): + self.property_change_type = _map_property_change_type_string( + change_data.get('propertyChangeType', 'NoEffect')) + self.path = change_data.get('path', '') + self.before = change_data.get('before') + self.after = change_data.get('after') + self.children = [] + + children_data = change_data.get('children', []) + for child_data in children_data: + child_property_change = PropertyChange(child_data) + self.children.append(child_property_change) + + def _map_change_type_string(change_type_str): + result = enum_mapping.get(change_type_str) + return result + + def _map_property_change_type_string(property_change_type_str): + result = property_enum_mapping.get(property_change_type_str) + return result + + result = WhatIfOperationResult() + + changes = what_if_json_result.get('changes', []) + for change_data in changes: + resource_change = ResourceChange(change_data) + result.changes.append(resource_change) + + potential_changes = what_if_json_result.get('potential_changes', []) + for change_data in potential_changes: + resource_change = ResourceChange(change_data) + result.potential_changes.append(resource_change) + + return result \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/sql/_params.py b/src/azure-cli/azure/cli/command_modules/sql/_params.py index 4cc939be113..7b74542656b 100644 --- a/src/azure-cli/azure/cli/command_modules/sql/_params.py +++ b/src/azure-cli/azure/cli/command_modules/sql/_params.py @@ -1916,7 +1916,7 @@ def _configure_security_policy_storage_params(arg_ctx): with self.argument_context('sql server create') as c: c.argument('location', arg_type=get_location_type_with_default_from_resource_group(self.cli_ctx)) - c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.') + c.argument('what_if', get_what_if_type()) # Create args that will be used to build up the Server object create_args_for_complex_type( diff --git a/src/azure-cli/azure/cli/command_modules/vm/_params.py b/src/azure-cli/azure/cli/command_modules/vm/_params.py index 5aea79baf34..91ec9788ebd 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/_params.py +++ b/src/azure-cli/azure/cli/command_modules/vm/_params.py @@ -413,7 +413,7 @@ def load_arguments(self, _): c.argument('workspace', is_preview=True, arg_group='Monitor', help='Name or ID of Log Analytics Workspace. If you specify the workspace through its name, the workspace should be in the same resource group with the vm, otherwise a new workspace will be created.') with self.argument_context('vm update') as c: - c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.') + c.argument('what_if', get_what_if_type()) c.argument('os_disk', min_api='2017-12-01', help="Managed OS disk ID or name to swap to") c.argument('write_accelerator', nargs='*', min_api='2017-12-01', help="enable/disable disk write accelerator. Use singular value 'true/false' to apply across, or specify individual disks, e.g.'os=true 1=true 2=true' for os disk and data disks with lun of 1 & 2") @@ -1063,7 +1063,7 @@ def load_arguments(self, _): for scope in ['vm create', 'vmss create']: with self.argument_context(scope) as c: c.argument('location', get_location_type(self.cli_ctx), help='Location in which to create VM and related resources. If default location is not configured, will default to the resource group\'s location') - c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.') + c.argument('what_if', get_what_if_type()) c.argument('tags', tags_type) c.argument('no_wait', help='Do not wait for the long-running operation to finish.') c.argument('validate', options_list=['--validate'], help='Generate and validate the ARM template without creating any resources.', action='store_true') From 3f86e44dd15c1d07d7352a74b9f390af86ec75e3 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Mon, 22 Sep 2025 15:23:00 +0800 Subject: [PATCH 13/28] Update what_if.py --- src/azure-cli-core/azure/cli/core/what_if.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py index 8fe9b01fd74..9fdfa2881eb 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -10,11 +10,6 @@ IMPORTANT: The what-if service requires client-side authentication to operate under the caller's subscription and permissions. Server-side authentication is not supported for what-if operations as it would not provide access to the caller's subscription. - -This client now uses AzureCliCredential to obtain an access token for the caller's subscription. - -The what-if service will use your configured credentials to access your subscription -and preview deployment changes under your permissions. """ from typing import Dict, Any, Optional from knack.log import get_logger From 499e4779e9a2af36f5252093b6ddc7515a7ba81e Mon Sep 17 00:00:00 2001 From: MoChilia Date: Mon, 22 Sep 2025 15:46:38 +0800 Subject: [PATCH 14/28] move call what-if to core --- src/azure-cli-core/azure/cli/core/what_if.py | 167 ++++++++++++++++ .../azure/cli/command_modules/util/custom.py | 182 ++---------------- .../util/tests/latest/test_whatif.py | 18 +- 3 files changed, 197 insertions(+), 170 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/what_if.py diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py new file mode 100644 index 00000000000..49e6ea07309 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -0,0 +1,167 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import threading +import time +import sys +import json +from requests import Request, Session +from knack.util import CLIError + + +def read_script_file(script_path): + try: + with open(script_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + raise CLIError(f"Script file not found: {script_path}") + except Exception as ex: + raise CLIError(f"Error reading script file: {ex}") + + +def get_auth_headers(cmd, subscription_id): + from azure.cli.core._profile import Profile + + resource = cmd.cli_ctx.cloud.endpoints.active_directory_resource_id + profile = Profile(cli_ctx=cmd.cli_ctx) + + try: + token_result = profile.get_raw_token(resource, subscription=subscription_id) + token_info, _, _ = token_result + token_type, token, _ = token_info + except Exception as token_ex: + raise CLIError(f"Failed to get authentication token: {token_ex}") + + return { + 'Authorization': f'{token_type} {token}', + 'Content-Type': 'application/json' + } + + +def make_what_if_request(payload, headers_dict): + request_completed = threading.Event() + + def _rotating_progress(): + """Simulate a rotating progress indicator.""" + chars = ["|", "\\", "/", "-"] + idx = 0 + while not request_completed.is_set(): + sys.stderr.write(f"\r{chars[idx % len(chars)]} Running") + sys.stderr.flush() + idx += 1 + time.sleep(0.2) + sys.stderr.write("\r" + " " * 20 + "\r") + sys.stderr.flush() + + try: + function_app_url = "https://azcli-script-insight.azurewebsites.net" + + progress_thread = threading.Thread(target=_rotating_progress) + progress_thread.daemon = True + progress_thread.start() + + session = Session() + req = Request(method="POST", url=f"{function_app_url}/api/what_if_preview", + headers=headers_dict, data=json.dumps(payload)) + prepared = session.prepare_request(req) + response = session.send(prepared) + request_completed.set() + progress_thread.join(timeout=0.5) + + return response + + except Exception as ex: + request_completed.set() + if 'progress_thread' in locals(): + progress_thread.join(timeout=0.5) + raise CLIError(f"Failed to connect to the what-if service: {ex}") + + +def convert_json_to_what_if_result(what_if_json_result): + from azure.cli.command_modules.resource._formatters import _change_type_to_weight, _property_change_type_to_weight + from collections import namedtuple + + enum_keys = list(_change_type_to_weight.keys()) + enum_mapping = {} + for enum_obj in enum_keys: + str_repr = str(enum_obj).lower() + if 'create' in str_repr: + enum_mapping['Create'] = enum_obj + elif 'delete' in str_repr: + enum_mapping['Delete'] = enum_obj + elif 'modify' in str_repr: + enum_mapping['Modify'] = enum_obj + elif 'deploy' in str_repr: + enum_mapping['Deploy'] = enum_obj + elif 'no_change' in str_repr or 'nochange' in str_repr: + enum_mapping['NoChange'] = enum_obj + elif 'ignore' in str_repr: + enum_mapping['Ignore'] = enum_obj + elif 'unsupported' in str_repr: + enum_mapping['Unsupported'] = enum_obj + elif 'no_effect' in str_repr or 'noeffect' in str_repr: + enum_mapping['NoEffect'] = enum_obj + + property_enum_keys = list(_property_change_type_to_weight.keys()) + property_enum_mapping = {} + for enum_obj in property_enum_keys: + str_repr = str(enum_obj).lower() + if 'create' in str_repr: + property_enum_mapping['Create'] = enum_obj + elif 'delete' in str_repr: + property_enum_mapping['Delete'] = enum_obj + elif 'modify' in str_repr: + property_enum_mapping['Modify'] = enum_obj + elif 'array' in str_repr: + property_enum_mapping['Array'] = enum_obj + elif 'no_effect' in str_repr or 'noeffect' in str_repr: + property_enum_mapping['NoEffect'] = enum_obj + + WhatIfOperationResult = namedtuple('WhatIfOperationResult', ['changes', 'potential_changes', 'diagnostics']) + ResourceChange = namedtuple('ResourceChange', ['change_type', 'resource_id', 'before', 'after', 'delta']) + PropertyChange = namedtuple('PropertyChange', ['property_change_type', 'path', 'before', 'after', 'children']) + + def _map_change_type_string(change_type_str): + return enum_mapping.get(change_type_str) + + def _map_property_change_type_string(property_change_type_str): + return property_enum_mapping.get(property_change_type_str) + + def _create_property_change(change_data): + property_change_type = _map_property_change_type_string( + change_data.get('propertyChangeType', 'NoEffect')) + path = change_data.get('path', '') + before = change_data.get('before') + after = change_data.get('after') + + children = [] + children_data = change_data.get('children', []) + for child_data in children_data: + children.append(_create_property_change(child_data)) + + return PropertyChange(property_change_type, path, before, after, children) + + def _create_resource_change(change_data): + change_type = _map_change_type_string(change_data.get('changeType', 'Unknown')) + resource_id = change_data.get('resourceId', '') + before = change_data.get('before') + after = change_data.get('after') + + delta = [] + delta_data = change_data.get('delta', []) + for property_data in delta_data: + delta.append(_create_property_change(property_data)) + + return ResourceChange(change_type, resource_id, before, after, delta) + + changes = [] + for change_data in what_if_json_result.get('changes', []): + changes.append(_create_resource_change(change_data)) + + potential_changes = [] + for change_data in what_if_json_result.get('potential_changes', []): + potential_changes.append(_create_resource_change(change_data)) + + return WhatIfOperationResult(changes, potential_changes, []) \ No newline at end of file diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index 2696ab2b71d..b935f82a3b7 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -375,183 +375,33 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument def show_what_if(cmd, script_path, no_pretty_print=False): from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.command_modules.resource._formatters import format_what_if_operation_result - from azure.cli.core._profile import Profile - import threading - import time - import sys - import json - from requests import Request, Session - - try: - with open(script_path, 'r', encoding='utf-8') as f: - script_content = f.read() - except FileNotFoundError: - raise CLIError(f"Script file not found: {script_path}") - except Exception as ex: - raise CLIError(f"Error reading script file: {ex}") + from azure.cli.core.what_if import (read_script_file, get_auth_headers, + make_what_if_request, convert_json_to_what_if_result) + script_content = read_script_file(script_path) subscription_id = get_subscription_id(cmd.cli_ctx) + payload = { "azcli_script": script_content, "subscription_id": subscription_id } - request_completed = threading.Event() - - def rotating_progress(): - """Simulate a rotating progress indicator, similar to the one displayed during long-running operations. - """ - chars = ["|", "\\", "/", "-"] - idx = 0 - while not request_completed.is_set(): - sys.stderr.write(f"\r{chars[idx % len(chars)]} Running") - sys.stderr.flush() - idx += 1 - time.sleep(0.2) - sys.stderr.write("\r" + " " * 20 + "\r") - sys.stderr.flush() - - try: - FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net" - resource = cmd.cli_ctx.cloud.endpoints.active_directory_resource_id - profile = Profile(cli_ctx=cmd.cli_ctx) - - try: - token_result = profile.get_raw_token(resource, subscription=subscription_id) - token_info, _, _ = token_result - token_type, token, _ = token_info - except Exception as token_ex: - request_completed.set() - raise CLIError(f"Failed to get authentication token: {token_ex}") - - headers_dict = {} - headers_dict['Authorization'] = '{} {}'.format(token_type, token) - headers_dict['Content-Type'] = 'application/json' - - progress_thread = threading.Thread(target=rotating_progress) - progress_thread.daemon = True - progress_thread.start() - - session = Session() - req = Request(method="POST", url=f"{FUNCTION_APP_URL}/api/what_if_preview", - headers=headers_dict, data=json.dumps(payload)) - prepared = session.prepare_request(req) - response = session.send(prepared) - request_completed.set() - - progress_thread.join(timeout=0.5) - - except Exception as ex: - request_completed.set() - if 'progress_thread' in locals(): - progress_thread.join(timeout=0.5) - raise CLIError(f"Failed to connect to the what-if service: {ex}") + headers_dict = get_auth_headers(cmd, subscription_id) + response = make_what_if_request(payload, headers_dict) try: raw_results = response.json() except ValueError as ex: raise CLIError(f"Failed to parse response from what-if service: {ex}") - what_if_result = raw_results.get('what_if_result', {}) - what_if_operation_result = _convert_json_to_what_if_result(what_if_result) - - if no_pretty_print: + success = raw_results.get('success') + if success is False: + return raw_results + if success is True: + what_if_result = raw_results.get('what_if_result', {}) + what_if_operation_result = convert_json_to_what_if_result(what_if_result) + if no_pretty_print: + return what_if_result + print(format_what_if_operation_result(what_if_operation_result, cmd.cli_ctx.enable_color)) return what_if_result - - print(format_what_if_operation_result(what_if_operation_result, cmd.cli_ctx.enable_color)) - return what_if_result - - -def _convert_json_to_what_if_result(what_if_json_result): - from azure.cli.command_modules.resource._formatters import _change_type_to_weight, _property_change_type_to_weight - - enum_keys = list(_change_type_to_weight.keys()) - enum_mapping = {} - for enum_obj in enum_keys: - str_repr = str(enum_obj).lower() - if 'create' in str_repr: - enum_mapping['Create'] = enum_obj - elif 'delete' in str_repr: - enum_mapping['Delete'] = enum_obj - elif 'modify' in str_repr: - enum_mapping['Modify'] = enum_obj - elif 'deploy' in str_repr: - enum_mapping['Deploy'] = enum_obj - elif 'no_change' in str_repr or 'nochange' in str_repr: - enum_mapping['NoChange'] = enum_obj - elif 'ignore' in str_repr: - enum_mapping['Ignore'] = enum_obj - elif 'unsupported' in str_repr: - enum_mapping['Unsupported'] = enum_obj - elif 'no_effect' in str_repr or 'noeffect' in str_repr: - enum_mapping['NoEffect'] = enum_obj - - property_enum_keys = list(_property_change_type_to_weight.keys()) - property_enum_mapping = {} - for enum_obj in property_enum_keys: - str_repr = str(enum_obj).lower() - if 'create' in str_repr: - property_enum_mapping['Create'] = enum_obj - elif 'delete' in str_repr: - property_enum_mapping['Delete'] = enum_obj - elif 'modify' in str_repr: - property_enum_mapping['Modify'] = enum_obj - elif 'array' in str_repr: - property_enum_mapping['Array'] = enum_obj - elif 'no_effect' in str_repr or 'noeffect' in str_repr: - property_enum_mapping['NoEffect'] = enum_obj - - class WhatIfOperationResult: - def __init__(self): - self.changes = [] - self.potential_changes = [] - self.diagnostics = [] - - class ResourceChange: - def __init__(self, change_data): - self.change_type = _map_change_type_string(change_data.get('changeType', 'Unknown')) - self.resource_id = change_data.get('resourceId', '') - self.before = change_data.get('before') - self.after = change_data.get('after') - self.delta = [] - - delta_data = change_data.get('delta', []) - for property_data in delta_data: - property_change = PropertyChange(property_data) - self.delta.append(property_change) - - class PropertyChange: - def __init__(self, change_data): - self.property_change_type = _map_property_change_type_string( - change_data.get('propertyChangeType', 'NoEffect')) - self.path = change_data.get('path', '') - self.before = change_data.get('before') - self.after = change_data.get('after') - self.children = [] - - children_data = change_data.get('children', []) - for child_data in children_data: - child_property_change = PropertyChange(child_data) - self.children.append(child_property_change) - - def _map_change_type_string(change_type_str): - result = enum_mapping.get(change_type_str) - return result - - def _map_property_change_type_string(property_change_type_str): - result = property_enum_mapping.get(property_change_type_str) - return result - - result = WhatIfOperationResult() - - changes = what_if_json_result.get('changes', []) - for change_data in changes: - resource_change = ResourceChange(change_data) - result.changes.append(resource_change) - - potential_changes = what_if_json_result.get('potential_changes', []) - for change_data in potential_changes: - resource_change = ResourceChange(change_data) - result.potential_changes.append(resource_change) - - return result + raise CLIError(f"Unexpected response from what-if service, got: {raw_results}") diff --git a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py index f3b955d4a7c..c8c9e8cdcda 100644 --- a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py +++ b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py @@ -17,10 +17,15 @@ def setUp(self): super().setUp() self.test_script_path = os.path.join(TEST_DIR, 'test_whatif_script.sh') - @patch('requests.Session.send') - def test_what_if_command(self, mock_session_send): + @patch('azure.cli.core.commands.client_factory.get_subscription_id') + @patch('azure.cli.core.what_if.get_auth_headers') + @patch('azure.cli.core.what_if.make_what_if_request') + def test_what_if_command(self, mock_make_request, mock_get_headers, mock_get_subscription_id): + mock_get_subscription_id.return_value = 'test-subscription-id' + mock_get_headers.return_value = {'Authorization': 'Bearer test-token'} mock_response = Mock() mock_response.json.return_value = { + "success": True, "what_if_result": { "changes": [ { @@ -39,14 +44,19 @@ def test_what_if_command(self, mock_session_send): "diagnostics": [] } } - mock_session_send.return_value = mock_response + mock_make_request.return_value = mock_response + result = self.cmd('az what-if --script-path "{}" --no-pretty-print'.format(self.test_script_path)) output = result.get_output_in_json() + self.assertIsInstance(output, dict) self.assertIn("changes", output) self.assertEqual(len(output["changes"]), 1) self.assertEqual(output["changes"][0]["changeType"], "Create") - mock_session_send.assert_called_once() + + mock_get_subscription_id.assert_called_once() + mock_get_headers.assert_called_once() + mock_make_request.assert_called_once() if __name__ == '__main__': From 6393d358078cadb67e5ddc946d7c0f13311eecbd Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Wed, 5 Nov 2025 11:47:54 +0800 Subject: [PATCH 15/28] remove what if command --- .../azure/cli/core/commands/__init__.py | 14 +-- src/azure-cli-core/azure/cli/core/what_if.py | 100 ++++++++++++++++-- .../azure/cli/command_modules/sql/custom.py | 2 +- .../azure/cli/command_modules/util/_help.py | 9 -- .../azure/cli/command_modules/util/_params.py | 3 - .../cli/command_modules/util/commands.py | 2 - .../azure/cli/command_modules/util/custom.py | 34 ------ .../util/tests/latest/test_whatif.py | 63 ----------- .../util/tests/latest/test_whatif_script.sh | 2 - src/azure-cli/service_name.json | 5 - 10 files changed, 99 insertions(+), 135 deletions(-) delete mode 100644 src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py delete mode 100644 src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif_script.sh diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index c5a13b9b0b1..e90ac3d6ff5 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -507,7 +507,6 @@ class AzCliCommandInvoker(CommandInvoker): # pylint: disable=too-many-statements,too-many-locals,too-many-branches def execute(self, args): args_copy = args[:] - from knack.events import (EVENT_INVOKER_PRE_CMD_TBL_CREATE, EVENT_INVOKER_POST_CMD_TBL_CREATE, EVENT_INVOKER_CMD_TBL_LOADED, EVENT_INVOKER_PRE_PARSE_ARGS, EVENT_INVOKER_POST_PARSE_ARGS, @@ -706,12 +705,12 @@ def _what_if(self, args): index = args.index('--subscription') if index + 1 < len(args): subscription_value = args[index + 1] - subscription_id = subscription_value + subscription_id = subscription_value else: from azure.cli.core.commands.client_factory import get_subscription_id subscription_id = get_subscription_id(self.cli_ctx) print(f"DEBUG: Using current login subscription ID: {subscription_id}") - + args = ["az"] + args if args[0] != 'az' else args command = " ".join(args) what_if_result = show_what_if(self.cli_ctx, command, subscription_id=subscription_id) @@ -724,14 +723,17 @@ def _what_if(self, args): # Return the formatted what-if output as the result # Similar to the normal flow in execute() method return CommandResultItem( - what_if_result, + what_if_result, table_transformer=None, is_query_active=self.data.get('query_active', False), exit_code=0 ) - except Exception as ex: + except (CLIError, ValueError, KeyError) as ex: # If what-if service fails, still show an informative message - return CommandResultItem(None, exit_code=1, error=CLIError(f'What-if preview failed: {str(ex)}\nNote: This was a preview operation. No actual changes were made.')) + return CommandResultItem(None, exit_code=1, + error=CLIError(f'What-if preview failed: {str(ex)}\n' + f'Note: This was a preview operation. ' + f'No actual changes were made.')) @staticmethod def _extract_parameter_names(args): diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py index d3d665a7a0d..f29a0bd73c5 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -3,7 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- - import threading import time import sys @@ -22,11 +21,11 @@ def read_script_file(script_path): raise CLIError(f"Error reading script file: {ex}") -def get_auth_headers(cmd, subscription_id): +def _get_auth_headers(cli_ctx, subscription_id): from azure.cli.core._profile import Profile - resource = cmd.cli_ctx.cloud.endpoints.active_directory_resource_id - profile = Profile(cli_ctx=cmd.cli_ctx) + resource = cli_ctx.cloud.endpoints.active_directory_resource_id + profile = Profile(cli_ctx=cli_ctx) try: token_result = profile.get_raw_token(resource, subscription=subscription_id) @@ -41,19 +40,67 @@ def get_auth_headers(cmd, subscription_id): } -def make_what_if_request(payload, headers_dict): +def _make_what_if_request(payload, headers_dict, cli_ctx=None): request_completed = threading.Event() def _rotating_progress(): """Simulate a rotating progress indicator.""" - chars = ["|", "\\", "/", "-"] + spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + fallback_chars = ["|", "\\", "/", "-"] + + try: + "⠋".encode(sys.stderr.encoding or 'utf-8') + chars = spinner_chars + except (UnicodeEncodeError, UnicodeDecodeError, LookupError): + chars = fallback_chars + + use_color = cli_ctx and getattr(cli_ctx, 'enable_color', False) + if use_color: + try: + CYAN = '\033[36m' + GREEN = '\033[32m' + YELLOW = '\033[33m' + BLUE = '\033[34m' + RESET = '\033[0m' + BOLD = '\033[1m' + except (UnicodeError, AttributeError): + use_color = False + + if not use_color: + CYAN = GREEN = YELLOW = BLUE = RESET = BOLD = '' + idx = 0 + start_time = time.time() + + # Simulate different stages, can be improved with real stages if available while not request_completed.is_set(): - sys.stderr.write(f"\r{chars[idx % len(chars)]} Running") + elapsed = time.time() - start_time + if elapsed < 10: + status = f"{CYAN}Connecting to what-if service{RESET}" + spinner_color = CYAN + elif elapsed < 30: + status = f"{BLUE}Analyzing Azure CLI script{RESET}" + spinner_color = BLUE + elif elapsed < 60: + status = f"{YELLOW}Processing what-if analysis{RESET}" + spinner_color = YELLOW + else: + status = f"{GREEN}Finalizing results{RESET}" + spinner_color = GREEN + elapsed_str = f"{BOLD}({elapsed:.0f}s){RESET}" + spinner = f"{spinner_color}{chars[idx % len(chars)]}{RESET}" + progress_line = f"{spinner} {status}... {elapsed_str}" + visible_length = len(progress_line) - (progress_line.count('\033[') * 5) + max_width = 100 + if visible_length > max_width: + truncated_status = status[:max_width - 30] + "..." + progress_line = f"{spinner} {truncated_status} {elapsed_str}" + sys.stderr.write(f"\r{' ' * 120}\r{progress_line}") sys.stderr.flush() idx += 1 - time.sleep(0.2) - sys.stderr.write("\r" + " " * 20 + "\r") + time.sleep(0.12) + clear_line = f"\r{' ' * 120}\r" + sys.stderr.write(clear_line) sys.stderr.flush() try: @@ -64,7 +111,7 @@ def _rotating_progress(): progress_thread.start() session = Session() - req = Request(method="POST", url=f"{function_app_url}/api/what_if_preview", + req = Request(method="POST", url=f"{function_app_url}/api/what_if_cli_preview", headers=headers_dict, data=json.dumps(payload)) prepared = session.prepare_request(req) response = session.send(prepared) @@ -166,3 +213,36 @@ def _create_resource_change(change_data): potential_changes.append(_create_resource_change(change_data)) return WhatIfOperationResult(changes, potential_changes, []) + + +def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pretty_print=False): + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.cli.command_modules.resource._formatters import format_what_if_operation_result + + if not subscription_id: + subscription_id = get_subscription_id(cli_ctx) + + payload = { + "azcli_script": azcli_script, + "subscription_id": subscription_id + } + + headers_dict = _get_auth_headers(cli_ctx, subscription_id) + response = _make_what_if_request(payload, headers_dict, cli_ctx) + + try: + raw_results = response.json() + except ValueError as ex: + raise CLIError(f"Failed to parse response from what-if service: {ex}, raw response: {response.text}") + + success = raw_results.get('success') + if success is False: + raise CLIError(f"Errors from what-if service: {raw_results}") + if success is True: + what_if_result = raw_results.get('what_if_result', {}) + what_if_operation_result = convert_json_to_what_if_result(what_if_result) + if no_pretty_print: + return what_if_result + print(format_what_if_operation_result(what_if_operation_result, cli_ctx.enable_color)) + return what_if_result + raise CLIError(f"Unexpected response from what-if service, got: {raw_results}") diff --git a/src/azure-cli/azure/cli/command_modules/sql/custom.py b/src/azure-cli/azure/cli/command_modules/sql/custom.py index aa965fba944..b1909b22203 100644 --- a/src/azure-cli/azure/cli/command_modules/sql/custom.py +++ b/src/azure-cli/azure/cli/command_modules/sql/custom.py @@ -4369,7 +4369,7 @@ def server_create( external_admin_principal_type=None, external_admin_sid=None, external_admin_name=None, - what_if=None, + what_if=None, # pylint: disable=unused-argument **kwargs): ''' Creates a server. diff --git a/src/azure-cli/azure/cli/command_modules/util/_help.py b/src/azure-cli/azure/cli/command_modules/util/_help.py index 241abcef88d..c3d0094efe3 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_help.py +++ b/src/azure-cli/azure/cli/command_modules/util/_help.py @@ -93,12 +93,3 @@ text: az demo byo-access-token --access-token "eyJ0eXAiO..." --subscription-id 00000000-0000-0000-0000-000000000000 """ -helps['what-if'] = """ -type: command -short-summary: Create a sandboxed what-if simulation of Azure CLI scripts to visualize infrastructure changes before execution. -examples: -- name: Simulate a what-if scenario for a provided script - text: az what-if --script-path "/path/to/your/script.sh" -- name: Simulate a what-if scenario for a specific subscription - text: az what-if --script-path "/path/to/your/script.sh" --subscription 00000000-0000-0000-0000-000000000000 -""" diff --git a/src/azure-cli/azure/cli/command_modules/util/_params.py b/src/azure-cli/azure/cli/command_modules/util/_params.py index 8c6930253d4..03319d17e6c 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_params.py +++ b/src/azure-cli/azure/cli/command_modules/util/_params.py @@ -52,6 +52,3 @@ def load_arguments(self, _): c.argument('access_token', help="Your own access token") c.argument('subscription_id', help="Subscription ID under which to list resource groups") - with self.argument_context('what-if') as c: - c.argument('script_path', help="Specify the path to a script file containing Azure CLI commands to be executed.") - c.argument('no_pretty_print', help="Disable pretty-printing of the output.") diff --git a/src/azure-cli/azure/cli/command_modules/util/commands.py b/src/azure-cli/azure/cli/command_modules/util/commands.py index 396d9e6a390..555e798db2b 100644 --- a/src/azure-cli/azure/cli/command_modules/util/commands.py +++ b/src/azure-cli/azure/cli/command_modules/util/commands.py @@ -23,5 +23,3 @@ def load_command_table(self, _): g.custom_command('save', 'secret_store_save') g.custom_command('load', 'secret_store_load') - with self.command_group('') as g: - g.custom_command('what-if', 'show_what_if', is_preview=True) diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index b935f82a3b7..9ae111a5928 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -371,37 +371,3 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument # Assume the access token expires in 1 year / 31536000 seconds return AccessToken(self.access_token, int(time.time()) + 31536000) - -def show_what_if(cmd, script_path, no_pretty_print=False): - from azure.cli.core.commands.client_factory import get_subscription_id - from azure.cli.command_modules.resource._formatters import format_what_if_operation_result - from azure.cli.core.what_if import (read_script_file, get_auth_headers, - make_what_if_request, convert_json_to_what_if_result) - - script_content = read_script_file(script_path) - subscription_id = get_subscription_id(cmd.cli_ctx) - - payload = { - "azcli_script": script_content, - "subscription_id": subscription_id - } - - headers_dict = get_auth_headers(cmd, subscription_id) - response = make_what_if_request(payload, headers_dict) - - try: - raw_results = response.json() - except ValueError as ex: - raise CLIError(f"Failed to parse response from what-if service: {ex}") - - success = raw_results.get('success') - if success is False: - return raw_results - if success is True: - what_if_result = raw_results.get('what_if_result', {}) - what_if_operation_result = convert_json_to_what_if_result(what_if_result) - if no_pretty_print: - return what_if_result - print(format_what_if_operation_result(what_if_operation_result, cmd.cli_ctx.enable_color)) - return what_if_result - raise CLIError(f"Unexpected response from what-if service, got: {raw_results}") diff --git a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py deleted file mode 100644 index c8c9e8cdcda..00000000000 --- a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py +++ /dev/null @@ -1,63 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import unittest -import os -from unittest.mock import patch, Mock -from azure.cli.testsdk import ScenarioTest - -TEST_DIR = os.path.dirname(os.path.realpath(__file__)) - - -class WhatIfTest(ScenarioTest): - - def setUp(self): - super().setUp() - self.test_script_path = os.path.join(TEST_DIR, 'test_whatif_script.sh') - - @patch('azure.cli.core.commands.client_factory.get_subscription_id') - @patch('azure.cli.core.what_if.get_auth_headers') - @patch('azure.cli.core.what_if.make_what_if_request') - def test_what_if_command(self, mock_make_request, mock_get_headers, mock_get_subscription_id): - mock_get_subscription_id.return_value = 'test-subscription-id' - mock_get_headers.return_value = {'Authorization': 'Bearer test-token'} - mock_response = Mock() - mock_response.json.return_value = { - "success": True, - "what_if_result": { - "changes": [ - { - "changeType": "Create", - "resourceId": "/subscriptions/test/resourceGroups/myrg/providers/Microsoft.Compute/virtualMachines/MyVM_01", - "before": None, - "after": { - "name": "MyVM_01", - "type": "Microsoft.Compute/virtualMachines", - "location": "eastus" - }, - "delta": [] - } - ], - "potential_changes": [], - "diagnostics": [] - } - } - mock_make_request.return_value = mock_response - - result = self.cmd('az what-if --script-path "{}" --no-pretty-print'.format(self.test_script_path)) - output = result.get_output_in_json() - - self.assertIsInstance(output, dict) - self.assertIn("changes", output) - self.assertEqual(len(output["changes"]), 1) - self.assertEqual(output["changes"][0]["changeType"], "Create") - - mock_get_subscription_id.assert_called_once() - mock_get_headers.assert_called_once() - mock_make_request.assert_called_once() - - -if __name__ == '__main__': - unittest.main() diff --git a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif_script.sh b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif_script.sh deleted file mode 100644 index 124797a2b26..00000000000 --- a/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif_script.sh +++ /dev/null @@ -1,2 +0,0 @@ -# Create a VM directly instead of using an ARM template -az vm create --resource-group myrg --name MyVM_01 --image UbuntuLTS --size Standard_D2s_v3 --admin-username azureuser --generate-ssh-keys diff --git a/src/azure-cli/service_name.json b/src/azure-cli/service_name.json index e8a059e7a2c..e260e066dc8 100644 --- a/src/azure-cli/service_name.json +++ b/src/azure-cli/service_name.json @@ -548,10 +548,5 @@ "Command": "az webapp", "AzureServiceName": "App Services", "URL": "https://learn.microsoft.com/rest/api/appservice/webapps" - }, - { - "Command": "az what-if", - "AzureServiceName": "Azure CLI", - "URL": "" } ] From 2d75315b66f2616330d84f75f77f4be769365317 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Wed, 5 Nov 2025 11:49:46 +0800 Subject: [PATCH 16/28] minor fix --- src/azure-cli/azure/cli/command_modules/util/_help.py | 1 - src/azure-cli/azure/cli/command_modules/util/_params.py | 1 - src/azure-cli/azure/cli/command_modules/util/commands.py | 1 - src/azure-cli/azure/cli/command_modules/util/custom.py | 1 - 4 files changed, 4 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/util/_help.py b/src/azure-cli/azure/cli/command_modules/util/_help.py index c3d0094efe3..84c2a529b68 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_help.py +++ b/src/azure-cli/azure/cli/command_modules/util/_help.py @@ -92,4 +92,3 @@ - name: List resource groups by bringing your own access token text: az demo byo-access-token --access-token "eyJ0eXAiO..." --subscription-id 00000000-0000-0000-0000-000000000000 """ - diff --git a/src/azure-cli/azure/cli/command_modules/util/_params.py b/src/azure-cli/azure/cli/command_modules/util/_params.py index 03319d17e6c..3e0aae88cd1 100644 --- a/src/azure-cli/azure/cli/command_modules/util/_params.py +++ b/src/azure-cli/azure/cli/command_modules/util/_params.py @@ -51,4 +51,3 @@ def load_arguments(self, _): with self.argument_context('demo byo-access-token') as c: c.argument('access_token', help="Your own access token") c.argument('subscription_id', help="Subscription ID under which to list resource groups") - diff --git a/src/azure-cli/azure/cli/command_modules/util/commands.py b/src/azure-cli/azure/cli/command_modules/util/commands.py index 555e798db2b..6f0c14030fc 100644 --- a/src/azure-cli/azure/cli/command_modules/util/commands.py +++ b/src/azure-cli/azure/cli/command_modules/util/commands.py @@ -22,4 +22,3 @@ def load_command_table(self, _): with self.command_group('demo secret-store') as g: g.custom_command('save', 'secret_store_save') g.custom_command('load', 'secret_store_load') - diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index 9ae111a5928..ea2f2ea0bd0 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -370,4 +370,3 @@ def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument from azure.cli.core.auth.util import AccessToken # Assume the access token expires in 1 year / 31536000 seconds return AccessToken(self.access_token, int(time.time()) + 31536000) - From 31dab53096ced3d3c8aba434bef546256557134f Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Wed, 5 Nov 2025 17:07:57 +0800 Subject: [PATCH 17/28] minor fix --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 7 +++---- src/azure-cli-core/azure/cli/core/what_if.py | 8 +++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index e90ac3d6ff5..e1c97f4332b 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -694,10 +694,9 @@ def execute(self, args): is_query_active=self.data['query_active']) def _what_if(self, args): - # DEBUG: Add logging to see if this method is called - print(f"DEBUG: _what_if called with command: {args}") + logger.debug("_what_if called with command: %s", args) if '--what-if' in args: - print("DEBUG: Entering what-if mode") + logger.debug("Entering what-if mode") from azure.cli.core.what_if import show_what_if try: # Get subscription ID with priority: --subscription parameter > current login subscription @@ -709,7 +708,7 @@ def _what_if(self, args): else: from azure.cli.core.commands.client_factory import get_subscription_id subscription_id = get_subscription_id(self.cli_ctx) - print(f"DEBUG: Using current login subscription ID: {subscription_id}") + logger.debug("Using current login subscription ID: %s", subscription_id) args = ["az"] + args if args[0] != 'az' else args command = " ".join(args) diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py index f29a0bd73c5..3314fe19d71 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -8,8 +8,10 @@ import sys import json from requests import Request, Session +from knack.log import get_logger from knack.util import CLIError +logger = get_logger(__name__) def read_script_file(script_path): try: @@ -111,10 +113,12 @@ def _rotating_progress(): progress_thread.start() session = Session() + logger.debug("url: %s/api/what_if_cli_preview; payload: %s", function_app_url, payload) req = Request(method="POST", url=f"{function_app_url}/api/what_if_cli_preview", headers=headers_dict, data=json.dumps(payload)) prepared = session.prepare_request(req) response = session.send(prepared) + logger.debug("response: %s", response) request_completed.set() progress_thread.join(timeout=0.5) @@ -215,7 +219,7 @@ def _create_resource_change(change_data): return WhatIfOperationResult(changes, potential_changes, []) -def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pretty_print=False): +def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pretty_print=False, export_bicep=False): from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.command_modules.resource._formatters import format_what_if_operation_result @@ -224,6 +228,7 @@ def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pre payload = { "azcli_script": azcli_script, + "export_bicep": export_bicep, "subscription_id": subscription_id } @@ -232,6 +237,7 @@ def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pre try: raw_results = response.json() + print(raw_results) except ValueError as ex: raise CLIError(f"Failed to parse response from what-if service: {ex}, raw response: {response.text}") From 4207bcaface6c15995a1b7bac6d475d51fc1a4a7 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Wed, 5 Nov 2025 17:59:22 +0800 Subject: [PATCH 18/28] add --export-bicep --- .../azure/cli/core/commands/__init__.py | 69 ++++++++++++++++++- src/azure-cli-core/azure/cli/core/what_if.py | 17 ++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index e1c97f4332b..1c3cda35c46 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -589,6 +589,10 @@ def execute(self, args): self.parser.enable_autocomplete() if '--what-if' in (args_copy): return self._what_if(args_copy) + elif '--export-bicep' in (args_copy): + # --export-bicep must be used with --what-if + logger.error("The --export-bicep parameter must be used together with --what-if") + return CommandResultItem(None, exit_code=1, error=CLIError('The --export-bicep parameter must be used together with --what-if')) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_PARSE_ARGS, args=args) parsed_args = self.parser.parse_args(args) self.cli_ctx.raise_event(EVENT_INVOKER_POST_PARSE_ARGS, command=parsed_args.command, args=parsed_args) @@ -699,6 +703,13 @@ def _what_if(self, args): logger.debug("Entering what-if mode") from azure.cli.core.what_if import show_what_if try: + # Check if --export-bicep is present + export_bicep = '--export-bicep' in args + if export_bicep: + # Remove --export-bicep from args for processing + args = [arg for arg in args if arg != '--export-bicep'] + logger.debug("Export bicep mode enabled") + # Get subscription ID with priority: --subscription parameter > current login subscription if '--subscription' in args: index = args.index('--subscription') @@ -712,7 +723,11 @@ def _what_if(self, args): args = ["az"] + args if args[0] != 'az' else args command = " ".join(args) - what_if_result = show_what_if(self.cli_ctx, command, subscription_id=subscription_id) + what_if_result = show_what_if(self.cli_ctx, command, subscription_id=subscription_id, export_bicep=export_bicep) + + # Save bicep templates if export_bicep is enabled and bicep_template exists + if export_bicep and isinstance(what_if_result, dict) and 'bicep_template' in what_if_result: + self._save_bicep_templates(args, what_if_result['bicep_template']) # Ensure output format is set for proper formatting # Default to 'json' if not already set @@ -734,6 +749,58 @@ def _what_if(self, args): f'Note: This was a preview operation. ' f'No actual changes were made.')) + def _save_bicep_templates(self, args, bicep_template): + """Save bicep templates to user's .azure directory""" + try: + import os + from datetime import datetime + from azure.cli.core._environment import get_config_dir + + # Extract command name (first argument after 'az') + command_parts = [arg for arg in args if not arg.startswith('-') and arg != 'az'] + if not command_parts: + logger.warning("Could not determine command name for bicep file naming") + return + + first_command = command_parts[0] + az_command = f"az_{first_command}" + + # Get full command for file naming (e.g., az_vm_create) + if len(command_parts) > 1: + full_command = f"az_{command_parts[0]}_{command_parts[1]}" + else: + full_command = az_command + "_command" + + # Create timestamp in yyyymmddhhMMss format + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + + # Get .azure config directory + config_dir = get_config_dir() + whatif_dir = os.path.join(config_dir, 'whatif', az_command) + + # Create directories if they don't exist + os.makedirs(whatif_dir, exist_ok=True) + logger.debug("Created bicep template directory: %s", whatif_dir) + + # Save main template + if 'main_template' in bicep_template: + main_file = os.path.join(whatif_dir, f"{full_command}_main_{timestamp}.bicep") + with open(main_file, 'w', encoding='utf-8') as f: + f.write(bicep_template['main_template']) + logger.info("Bicep main template saved to: %s", main_file) + + # Save module templates if they exist + if 'module_templates' in bicep_template and bicep_template['module_templates']: + for i, module_template in enumerate(bicep_template['module_templates'], 1): + module_suffix = f"module{i}" if i > 1 else "module" + module_file = os.path.join(whatif_dir, f"{full_command}_{module_suffix}_{timestamp}.bicep") + with open(module_file, 'w', encoding='utf-8') as f: + f.write(module_template) + logger.info("Bicep module template saved to: %s", module_file) + + except Exception as ex: + logger.warning("Failed to save bicep templates: %s", str(ex)) + @staticmethod def _extract_parameter_names(args): # note: name start with more than 2 '-' will be treated as value e.g. certs in PEM format diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py index 3314fe19d71..170eda73770 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -237,7 +237,8 @@ def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pre try: raw_results = response.json() - print(raw_results) + # Only print raw results in debug mode + logger.debug("Raw what-if service response: %s", raw_results) except ValueError as ex: raise CLIError(f"Failed to parse response from what-if service: {ex}, raw response: {response.text}") @@ -247,8 +248,18 @@ def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pre if success is True: what_if_result = raw_results.get('what_if_result', {}) what_if_operation_result = convert_json_to_what_if_result(what_if_result) + + # If export_bicep is enabled and bicep_template exists, include it in the result + result_data = what_if_result.copy() + if export_bicep and 'bicep_template' in raw_results: + result_data['bicep_template'] = raw_results['bicep_template'] + logger.debug("Bicep template included in result: %s", raw_results['bicep_template']) + if no_pretty_print: - return what_if_result + return result_data + + # Print the formatted what-if result (but not bicep template unless in debug mode) print(format_what_if_operation_result(what_if_operation_result, cli_ctx.enable_color)) - return what_if_result + + return result_data raise CLIError(f"Unexpected response from what-if service, got: {raw_results}") From 8402c51951c889352563ec806408c5608b5b6a0f Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Thu, 6 Nov 2025 14:50:02 +0800 Subject: [PATCH 19/28] minor fix --- .../azure/cli/core/commands/__init__.py | 47 +++++++++++++------ src/azure-cli-core/azure/cli/core/what_if.py | 1 - 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 1c3cda35c46..b59cd595edf 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -705,30 +705,42 @@ def _what_if(self, args): try: # Check if --export-bicep is present export_bicep = '--export-bicep' in args + + # Remove both --what-if and --export-bicep from args for processing + clean_args = [arg for arg in args if arg not in ['--what-if', '--export-bicep']] + if export_bicep: - # Remove --export-bicep from args for processing - args = [arg for arg in args if arg != '--export-bicep'] logger.debug("Export bicep mode enabled") # Get subscription ID with priority: --subscription parameter > current login subscription - if '--subscription' in args: - index = args.index('--subscription') - if index + 1 < len(args): - subscription_value = args[index + 1] + if '--subscription' in clean_args: + index = clean_args.index('--subscription') + if index + 1 < len(clean_args): + subscription_value = clean_args[index + 1] subscription_id = subscription_value else: from azure.cli.core.commands.client_factory import get_subscription_id subscription_id = get_subscription_id(self.cli_ctx) logger.debug("Using current login subscription ID: %s", subscription_id) - args = ["az"] + args if args[0] != 'az' else args - command = " ".join(args) + clean_args = ["az"] + clean_args if clean_args[0] != 'az' else clean_args + command = " ".join(clean_args) what_if_result = show_what_if(self.cli_ctx, command, subscription_id=subscription_id, export_bicep=export_bicep) # Save bicep templates if export_bicep is enabled and bicep_template exists + bicep_files = [] if export_bicep and isinstance(what_if_result, dict) and 'bicep_template' in what_if_result: - self._save_bicep_templates(args, what_if_result['bicep_template']) - + bicep_files = self._save_bicep_templates(clean_args, what_if_result['bicep_template']) + what_if_result.pop('bicep_template', None) + + # Print bicep file locations if any were saved + if bicep_files: + from azure.cli.core.style import Style, print_styled_text + print_styled_text((Style.WARNING, "\nBicep templates saved to:")) + for file_path in bicep_files: + print_styled_text((Style.WARNING, f" {file_path}")) + print("") + # Ensure output format is set for proper formatting # Default to 'json' if not already set if 'output' not in self.cli_ctx.invocation.data or self.cli_ctx.invocation.data['output'] is None: @@ -750,7 +762,10 @@ def _what_if(self, args): f'No actual changes were made.')) def _save_bicep_templates(self, args, bicep_template): - """Save bicep templates to user's .azure directory""" + """Save bicep templates to user's .azure directory + Returns a list of saved file paths + """ + saved_files = [] try: import os from datetime import datetime @@ -760,7 +775,7 @@ def _save_bicep_templates(self, args, bicep_template): command_parts = [arg for arg in args if not arg.startswith('-') and arg != 'az'] if not command_parts: logger.warning("Could not determine command name for bicep file naming") - return + return saved_files first_command = command_parts[0] az_command = f"az_{first_command}" @@ -787,7 +802,8 @@ def _save_bicep_templates(self, args, bicep_template): main_file = os.path.join(whatif_dir, f"{full_command}_main_{timestamp}.bicep") with open(main_file, 'w', encoding='utf-8') as f: f.write(bicep_template['main_template']) - logger.info("Bicep main template saved to: %s", main_file) + logger.debug("Bicep main template saved to: %s", main_file) + saved_files.append(main_file) # Save module templates if they exist if 'module_templates' in bicep_template and bicep_template['module_templates']: @@ -796,10 +812,13 @@ def _save_bicep_templates(self, args, bicep_template): module_file = os.path.join(whatif_dir, f"{full_command}_{module_suffix}_{timestamp}.bicep") with open(module_file, 'w', encoding='utf-8') as f: f.write(module_template) - logger.info("Bicep module template saved to: %s", module_file) + logger.debug("Bicep module template saved to: %s", module_file) + saved_files.append(module_file) except Exception as ex: logger.warning("Failed to save bicep templates: %s", str(ex)) + + return saved_files @staticmethod def _extract_parameter_names(args): diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py index 170eda73770..4c627aa9f72 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -258,7 +258,6 @@ def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pre if no_pretty_print: return result_data - # Print the formatted what-if result (but not bicep template unless in debug mode) print(format_what_if_operation_result(what_if_operation_result, cli_ctx.enable_color)) return result_data From 9c442703da5b0da28f4f3e26c129c9f1a54435a6 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Tue, 11 Nov 2025 11:57:16 +0800 Subject: [PATCH 20/28] add whitelist --- .../azure/cli/core/commands/__init__.py | 48 +++++++++++++++++++ .../azure/cli/core/commands/parameters.py | 10 ++++ .../cli/command_modules/network/_params.py | 7 ++- .../aaz/latest/network/vnet/_create.py | 14 ++++++ .../aaz/latest/network/vnet/_update.py | 14 ++++++ .../azure/cli/command_modules/sql/_params.py | 2 - .../azure/cli/command_modules/sql/custom.py | 1 - .../cli/command_modules/storage/_params.py | 11 ++++- .../storage/operations/account.py | 2 +- .../storage/operations/blob.py | 3 +- .../storage/operations/fileshare.py | 3 +- .../azure/cli/command_modules/vm/_params.py | 14 +++++- .../azure/cli/command_modules/vm/custom.py | 13 +++-- 13 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index b59cd595edf..b533a396fa3 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -701,6 +701,13 @@ def _what_if(self, args): logger.debug("_what_if called with command: %s", args) if '--what-if' in args: logger.debug("Entering what-if mode") + + # Check if command is in whitelist + if not self._is_command_supported_for_what_if(args): + error_msg = ("\"--what-if\" argument is not supported for this command.") + logger.error(error_msg) + return CommandResultItem(None, exit_code=1, error=CLIError(error_msg)) + from azure.cli.core.what_if import show_what_if try: # Check if --export-bicep is present @@ -761,6 +768,47 @@ def _what_if(self, args): f'Note: This was a preview operation. ' f'No actual changes were made.')) + def _is_command_supported_for_what_if(self, args): + """Check if the command is in the what-if whitelist + + Args: + args: List of command arguments + + Returns: + bool: True if command is supported, False otherwise + """ + # Define supported commands for what-if functionality + WHAT_IF_SUPPORTED_COMMANDS = { + 'vm create', + 'vm update', + 'storage account create', + 'storage container create', + 'storage share create', + 'network vnet create', + 'network vnet update', + 'storage account network-rule add', + 'vm disk attach', + 'vm disk detach', + 'vm nic remove' + } + + # Extract command parts (skip 'az' and flags) + command_parts = [] + for arg in args: + if arg == 'az': + continue + if arg.startswith('-'): + break + command_parts.append(arg) + + # Join command parts to form the command string + if command_parts: + command = ' '.join(command_parts) + logger.debug("Checking what-if support for command: %s", command) + return command in WHAT_IF_SUPPORTED_COMMANDS + + return False + def _save_bicep_templates(self, args, bicep_template): """Save bicep templates to user's .azure directory Returns a list of saved file paths diff --git a/src/azure-cli-core/azure/cli/core/commands/parameters.py b/src/azure-cli-core/azure/cli/core/commands/parameters.py index c165dac37ba..bbdf2f83b90 100644 --- a/src/azure-cli-core/azure/cli/core/commands/parameters.py +++ b/src/azure-cli-core/azure/cli/core/commands/parameters.py @@ -278,6 +278,16 @@ def get_what_if_type(): return what_if_type +def get_export_bicep_type(): + export_bicep_type = CLIArgumentType( + options_list=['--export-bicep'], + help="Export the Bicep template corresponding to the what-if analysis. " + "This parameter must be used together with --what-if.", + is_preview=True + ) + return export_bicep_type + + deployment_name_type = CLIArgumentType( help=argparse.SUPPRESS, required=False, diff --git a/src/azure-cli/azure/cli/command_modules/network/_params.py b/src/azure-cli/azure/cli/command_modules/network/_params.py index 8dd89d8c0c2..daf9a6f4bc2 100644 --- a/src/azure-cli/azure/cli/command_modules/network/_params.py +++ b/src/azure-cli/azure/cli/command_modules/network/_params.py @@ -10,7 +10,8 @@ from azure.cli.core.commands.parameters import (get_location_type, get_resource_name_completion_list, tags_type, zone_type, zones_type, - file_type, get_three_state_flag, get_enum_type) + file_type, get_three_state_flag, get_enum_type, + get_what_if_type, get_export_bicep_type) from azure.cli.core.commands.validators import get_default_location_from_resource_group from azure.cli.core.commands.template_create import get_folded_parameter_help_string from azure.cli.core.local_context import LocalContextAttribute, LocalContextAction, ALL @@ -693,6 +694,8 @@ def load_arguments(self, _): c.argument('vnet_name', virtual_network_name_type, options_list=['--name', '-n'], completer=None, local_context_attribute=LocalContextAttribute(name='vnet_name', actions=[LocalContextAction.SET], scopes=[ALL])) c.argument('edge_zone', edge_zone) + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) with self.argument_context('network vnet create', arg_group='Subnet') as c: c.argument('subnet_name', help='Name of a new subnet to create within the VNet.', @@ -703,6 +706,8 @@ def load_arguments(self, _): with self.argument_context('network vnet update') as c: c.argument('address_prefixes', nargs='+') + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) with self.argument_context('network vnet delete') as c: c.argument('virtual_network_name', local_context_attribute=None) diff --git a/src/azure-cli/azure/cli/command_modules/network/aaz/latest/network/vnet/_create.py b/src/azure-cli/azure/cli/command_modules/network/aaz/latest/network/vnet/_create.py index 1024e39f31b..7ddce4a2160 100644 --- a/src/azure-cli/azure/cli/command_modules/network/aaz/latest/network/vnet/_create.py +++ b/src/azure-cli/azure/cli/command_modules/network/aaz/latest/network/vnet/_create.py @@ -61,6 +61,20 @@ def _build_arguments_schema(cls, *args, **kwargs): help="The virtual network (VNet) name.", required=True, ) + _args_schema.what_if = AAZBoolArg( + options=["--what-if"], + help="Preview the changes that will be made without actually executing the command. " + "This will call the what-if service to compare the current state with the expected state after execution.", + default=False, + is_preview=True, + ) + _args_schema.export_bicep = AAZBoolArg( + options=["--export-bicep"], + help="Export the Bicep template corresponding to the what-if analysis. " + "This parameter must be used together with --what-if.", + default=False, + is_preview=True, + ) _args_schema.extended_location = AAZObjectArg( options=["--extended-location"], help="The extended location of the virtual network.", diff --git a/src/azure-cli/azure/cli/command_modules/network/aaz/latest/network/vnet/_update.py b/src/azure-cli/azure/cli/command_modules/network/aaz/latest/network/vnet/_update.py index 600935a2e66..4b8f5caf5ac 100644 --- a/src/azure-cli/azure/cli/command_modules/network/aaz/latest/network/vnet/_update.py +++ b/src/azure-cli/azure/cli/command_modules/network/aaz/latest/network/vnet/_update.py @@ -112,6 +112,20 @@ def _build_arguments_schema(cls, *args, **kwargs): nullable=True, enum={"Basic": "Basic", "Disabled": "Disabled"}, ) + _args_schema.what_if = AAZBoolArg( + options=["--what-if"], + help="Preview the changes that will be made without actually executing the command. " + "This will call the what-if service to compare the current state with the expected state after execution.", + default=False, + is_preview=True, + ) + _args_schema.export_bicep = AAZBoolArg( + options=["--export-bicep"], + help="Export the Bicep template corresponding to the what-if analysis. " + "This parameter must be used together with --what-if.", + default=False, + is_preview=True, + ) address_prefixes = cls._args_schema.address_prefixes address_prefixes.Element = AAZStrArg( diff --git a/src/azure-cli/azure/cli/command_modules/sql/_params.py b/src/azure-cli/azure/cli/command_modules/sql/_params.py index ce2c94119fa..bb6064eac02 100644 --- a/src/azure-cli/azure/cli/command_modules/sql/_params.py +++ b/src/azure-cli/azure/cli/command_modules/sql/_params.py @@ -40,7 +40,6 @@ get_enum_type, get_resource_name_completion_list, get_location_type, - get_what_if_type, tags_type, resource_group_name_type ) @@ -1914,7 +1913,6 @@ def _configure_security_policy_storage_params(arg_ctx): with self.argument_context('sql server create') as c: c.argument('location', arg_type=get_location_type_with_default_from_resource_group(self.cli_ctx)) - c.argument('what_if', get_what_if_type()) # Create args that will be used to build up the Server object create_args_for_complex_type( diff --git a/src/azure-cli/azure/cli/command_modules/sql/custom.py b/src/azure-cli/azure/cli/command_modules/sql/custom.py index 0a4506f19e2..17d2d4162e6 100644 --- a/src/azure-cli/azure/cli/command_modules/sql/custom.py +++ b/src/azure-cli/azure/cli/command_modules/sql/custom.py @@ -4372,7 +4372,6 @@ def server_create( external_admin_principal_type=None, external_admin_sid=None, external_admin_name=None, - what_if=None, # pylint: disable=unused-argument **kwargs): ''' Creates a server. diff --git a/src/azure-cli/azure/cli/command_modules/storage/_params.py b/src/azure-cli/azure/cli/command_modules/storage/_params.py index 9185b7d35ac..ec01f018cd1 100644 --- a/src/azure-cli/azure/cli/command_modules/storage/_params.py +++ b/src/azure-cli/azure/cli/command_modules/storage/_params.py @@ -6,7 +6,8 @@ from azure.cli.core.profiles import ResourceType from azure.cli.core.commands.validators import get_default_location_from_resource_group from azure.cli.core.commands.parameters import (tags_type, file_type, get_location_type, - get_enum_type, get_three_state_flag, edge_zone_type) + get_enum_type, get_three_state_flag, edge_zone_type, + get_what_if_type, get_export_bicep_type) from azure.cli.core.local_context import LocalContextAttribute, LocalContextAction, ALL from ._validators import (get_datetime_type, validate_metadata, get_permission_validator, get_permission_help_string, @@ -338,6 +339,8 @@ def load_arguments(self, _): # pylint: disable=too-many-locals, too-many-statem c.argument('kind', help='Indicate the type of storage account.', arg_type=get_enum_type(t_kind), default='StorageV2' if self.cli_ctx.cloud.profile == 'latest' else 'Storage') + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) c.argument('https_only', arg_type=get_three_state_flag(), help='Allow https traffic only to storage service if set to true. The default value is true.') c.argument('tags', tags_type) @@ -668,6 +671,8 @@ def load_arguments(self, _): # pylint: disable=too-many-locals, too-many-statem c.argument('action', action_type) c.argument('resource_id', help='The resource id to add in network rule.', arg_group='Resource Access Rule') c.argument('tenant_id', help='The tenant id to add in network rule.', arg_group='Resource Access Rule') + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) with self.argument_context('storage account blob-service-properties', resource_type=ResourceType.MGMT_STORAGE) as c: @@ -1544,6 +1549,8 @@ def load_arguments(self, _): # pylint: disable=too-many-locals, too-many-statem c.argument('prevent_encryption_scope_override', options_list=['--prevent-encryption-scope-override', '-p'], arg_type=get_three_state_flag(), arg_group='Encryption Policy', is_preview=True, help='Block override of encryption scope from the container default.') + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) with self.argument_context('storage container delete') as c: c.argument('fail_not_exist', help='Throw an exception if the container does not exist.') @@ -1835,6 +1842,8 @@ def load_arguments(self, _): # pylint: disable=too-many-locals, too-many-statem arg_type=get_three_state_flag(), help='Specifies whether the snapshot virtual directory should be accessible at the root of the ' 'share mount point when NFS is enabled. If not specified, it will be accessible.') + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) with self.argument_context('storage share url') as c: c.extra('unc', action='store_true', help='Output UNC network path.') diff --git a/src/azure-cli/azure/cli/command_modules/storage/operations/account.py b/src/azure-cli/azure/cli/command_modules/storage/operations/account.py index 50a76358e0f..a3c22bed3e5 100644 --- a/src/azure-cli/azure/cli/command_modules/storage/operations/account.py +++ b/src/azure-cli/azure/cli/command_modules/storage/operations/account.py @@ -79,7 +79,7 @@ def create_storage_account(cmd, resource_group_name, account_name, sku=None, loc immutability_period_since_creation_in_days=None, immutability_policy_state=None, allow_protected_append_writes=None, public_network_access=None, dns_endpoint_type=None, enable_smb_oauth=None, zones=None, zone_placement_policy=None, - enable_blob_geo_priority_replication=None): + enable_blob_geo_priority_replication=None, what_if=None, export_bicep=None): StorageAccountCreateParameters, Kind, Sku, CustomDomain, AccessTier, Identity, Encryption, NetworkRuleSet = \ cmd.get_models('StorageAccountCreateParameters', 'Kind', 'Sku', 'CustomDomain', 'AccessTier', 'Identity', 'Encryption', 'NetworkRuleSet') diff --git a/src/azure-cli/azure/cli/command_modules/storage/operations/blob.py b/src/azure-cli/azure/cli/command_modules/storage/operations/blob.py index 6b09039eb19..e232ce46f14 100644 --- a/src/azure-cli/azure/cli/command_modules/storage/operations/blob.py +++ b/src/azure-cli/azure/cli/command_modules/storage/operations/blob.py @@ -120,7 +120,8 @@ def container_rm_exists(client, resource_group_name, account_name, container_nam # pylint: disable=unused-argument def create_container(client, container_name, resource_group_name=None, metadata=None, public_access=None, fail_on_exist=False, timeout=None, - default_encryption_scope=None, prevent_encryption_scope_override=None): + default_encryption_scope=None, prevent_encryption_scope_override=None, + what_if=None, export_bicep=None): encryption_scope = None if default_encryption_scope is not None or prevent_encryption_scope_override is not None: encryption_scope = { diff --git a/src/azure-cli/azure/cli/command_modules/storage/operations/fileshare.py b/src/azure-cli/azure/cli/command_modules/storage/operations/fileshare.py index 3b4aa5d2655..17f25d183d8 100644 --- a/src/azure-cli/azure/cli/command_modules/storage/operations/fileshare.py +++ b/src/azure-cli/azure/cli/command_modules/storage/operations/fileshare.py @@ -27,7 +27,8 @@ def list_shares(client, prefix=None, marker=None, num_results=None, return result -def create_share(cmd, client, metadata=None, quota=None, fail_on_exist=False, timeout=None, **kwargs): +def create_share(cmd, client, metadata=None, quota=None, fail_on_exist=False, timeout=None, + what_if=False, export_bicep=False, **kwargs): from azure.core.exceptions import HttpResponseError try: client.create_share(metadata=metadata, quota=quota, timeout=timeout, **kwargs) diff --git a/src/azure-cli/azure/cli/command_modules/vm/_params.py b/src/azure-cli/azure/cli/command_modules/vm/_params.py index 7f46236ddc3..d7aa5ee3de4 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/_params.py +++ b/src/azure-cli/azure/cli/command_modules/vm/_params.py @@ -13,7 +13,7 @@ from azure.cli.core.commands.validators import ( get_default_location_from_resource_group, validate_file_or_dict) from azure.cli.core.commands.parameters import ( - get_location_type, get_what_if_type, get_resource_name_completion_list, tags_type, get_three_state_flag, + get_location_type, get_what_if_type, get_export_bicep_type, get_resource_name_completion_list, tags_type, get_three_state_flag, file_type, get_enum_type, zone_type, zones_type) from azure.cli.command_modules.vm._actions import _resource_not_exists from azure.cli.command_modules.vm._completers import ( @@ -416,6 +416,7 @@ def load_arguments(self, _): with self.argument_context('vm update') as c: c.argument('what_if', get_what_if_type()) + c.argument('export_bicep', get_export_bicep_type()) c.argument('os_disk', min_api='2017-12-01', help="Managed OS disk ID or name to swap to") c.argument('write_accelerator', nargs='*', min_api='2017-12-01', help="enable/disable disk write accelerator. Use singular value 'true/false' to apply across, or specify individual disks, e.g.'os=true 1=true 2=true' for os disk and data disks with lun of 1 & 2") @@ -465,6 +466,8 @@ def load_arguments(self, _): c.argument('zone_placement_policy', arg_type=get_enum_type(self.get_models('ZonePlacementPolicyType')), min_api='2024-11-01', help="Specify the policy for virtual machine's placement in availability zone") c.argument('include_zones', nargs='+', min_api='2024-11-01', help='If "--zone-placement-policy" is set to "Any", availability zone selected by the system must be present in the list of availability zones passed with "--include-zones". If "--include-zones" is not provided, all availability zones in region will be considered for selection.') c.argument('exclude_zones', nargs='+', min_api='2024-11-01', help='If "--zone-placement-policy" is set to "Any", availability zone selected by the system must not be present in the list of availability zones passed with "excludeZones". If "--exclude-zones" is not provided, all availability zones in region will be considered for selection.') + c.argument('what_if', get_what_if_type()) + c.argument('export_bicep', get_export_bicep_type()) for scope in ['vm create', 'vm update']: with self.argument_context(scope) as c: @@ -553,11 +556,15 @@ def load_arguments(self, _): c.argument('source_disk_restore_point', options_list=['--source-disk-restore-point', '--source-disk-rp'], nargs='+', min_api='2024-11-01', help='create a data disk from a disk restore point. Can use the ID of a disk restore point.') c.argument('new_names_of_source_snapshots_or_disks', options_list=['--new-names-of-source-snapshots-or-disks', '--new-names-of-sr'], nargs='+', min_api='2024-11-01', help='The name of create new data disk from a snapshot or another disk.') c.argument('new_names_of_source_disk_restore_point', options_list=['--new-names-of-source-disk-restore-point', '--new-names-of-rp'], nargs='+', min_api='2024-11-01', help='The name of create new data disk from a disk restore point.') + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) with self.argument_context('vm disk detach') as c: c.argument('disk_name', arg_type=name_arg_type, help='The data disk name.') c.argument('force_detach', action='store_true', min_api='2020-12-01', help='Force detach managed data disks from a VM.') c.argument('disk_ids', nargs='+', min_api='2024-03-01', help='The disk IDs of the managed disk (space-delimited).') + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) with self.argument_context('vm encryption enable') as c: c.argument('encrypt_format_all', action='store_true', help='Encrypts-formats data disks instead of encrypting them. Encrypt-formatting is a lot faster than in-place encryption but wipes out the partition getting encrypt-formatted. (Only supported for Linux virtual machines.)') @@ -627,6 +634,10 @@ def load_arguments(self, _): with self.argument_context('vm nic show') as c: c.argument('nic', help='NIC name or ID.', validator=validate_vm_nic) + + with self.argument_context('vm nic remove') as c: + c.argument('what_if', arg_type=get_what_if_type()) + c.argument('export_bicep', arg_type=get_export_bicep_type()) with self.argument_context('vm unmanaged-disk') as c: c.argument('new', action='store_true', help='Create a new disk.') @@ -1063,7 +1074,6 @@ def load_arguments(self, _): for scope in ['vm create', 'vmss create']: with self.argument_context(scope) as c: c.argument('location', get_location_type(self.cli_ctx), help='Location in which to create VM and related resources. If default location is not configured, will default to the resource group\'s location') - c.argument('what_if', get_what_if_type()) c.argument('tags', tags_type) c.argument('no_wait', help='Do not wait for the long-running operation to finish.') c.argument('validate', options_list=['--validate'], help='Generate and validate the ARM template without creating any resources.', action='store_true') diff --git a/src/azure-cli/azure/cli/command_modules/vm/custom.py b/src/azure-cli/azure/cli/command_modules/vm/custom.py index 23a2f5025c2..14cfcfa89fb 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/custom.py +++ b/src/azure-cli/azure/cli/command_modules/vm/custom.py @@ -874,7 +874,7 @@ def create_vm(cmd, vm_name, resource_group_name, image=None, size='Standard_DS1_ enable_user_redeploy_scheduled_events=None, zone_placement_policy=None, include_zones=None, exclude_zones=None, align_regional_disks_to_vm_zone=None, wire_server_mode=None, imds_mode=None, wire_server_access_control_profile_reference_id=None, imds_access_control_profile_reference_id=None, - key_incarnation_id=None, add_proxy_agent_extension=None, what_if=False): + key_incarnation_id=None, add_proxy_agent_extension=None, what_if=False, export_bicep=False): from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import random_string, hash_string @@ -1652,7 +1652,7 @@ def update_vm(cmd, resource_group_name, vm_name, os_disk=None, disk_caching=None align_regional_disks_to_vm_zone=None, wire_server_mode=None, imds_mode=None, add_proxy_agent_extension=None, wire_server_access_control_profile_reference_id=None, imds_access_control_profile_reference_id=None, - key_incarnation_id=None, **kwargs): + key_incarnation_id=None, what_if=False, export_bicep=False, **kwargs): from azure.mgmt.core.tools import parse_resource_id, resource_id, is_valid_resource_id from ._vm_utils import update_write_accelerator_settings, update_disk_caching SecurityProfile, UefiSettings = cmd.get_models('SecurityProfile', 'UefiSettings') @@ -2148,7 +2148,8 @@ def show_default_diagnostics_configuration(is_windows_os=False): def attach_managed_data_disk(cmd, resource_group_name, vm_name, disk=None, ids=None, disks=None, new=False, sku=None, size_gb=None, lun=None, caching=None, enable_write_accelerator=False, disk_ids=None, source_snapshots_or_disks=None, source_disk_restore_point=None, - new_names_of_source_snapshots_or_disks=None, new_names_of_source_disk_restore_point=None): + new_names_of_source_snapshots_or_disks=None, new_names_of_source_disk_restore_point=None, + what_if=False, export_bicep=False): # attach multiple managed disks using disk attach API vm = get_vm_to_update(cmd, resource_group_name, vm_name) if not new and not sku and not size_gb and disk_ids is not None: @@ -2273,7 +2274,8 @@ def detach_unmanaged_data_disk(cmd, resource_group_name, vm_name, disk_name): # endregion -def detach_managed_data_disk(cmd, resource_group_name, vm_name, disk_name=None, force_detach=None, disk_ids=None): +def detach_managed_data_disk(cmd, resource_group_name, vm_name, disk_name=None, force_detach=None, disk_ids=None, + what_if=False, export_bicep=False): if disk_ids is not None: data_disks = [] for disk_item in disk_ids: @@ -2716,7 +2718,8 @@ def add_vm_nic(cmd, resource_group_name, vm_name, nics, primary_nic=None): return _update_vm_nics(cmd, vm, existing_nics + new_nics, primary_nic) -def remove_vm_nic(cmd, resource_group_name, vm_name, nics, primary_nic=None): +def remove_vm_nic(cmd, resource_group_name, vm_name, nics, primary_nic=None, + what_if=False, export_bicep=False): def to_delete(nic_id): return [n for n in nics_to_delete if n.id.lower() == nic_id.lower()] From 7d89ee37352b276f32ef38b7bab7646c39ee5df6 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Tue, 11 Nov 2025 12:08:50 +0800 Subject: [PATCH 21/28] minor fix --- .../azure/cli/command_modules/storage/operations/account.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/storage/operations/account.py b/src/azure-cli/azure/cli/command_modules/storage/operations/account.py index a3c22bed3e5..49b17b2eb23 100644 --- a/src/azure-cli/azure/cli/command_modules/storage/operations/account.py +++ b/src/azure-cli/azure/cli/command_modules/storage/operations/account.py @@ -746,7 +746,8 @@ def list_network_rules(client, resource_group_name, account_name): def add_network_rule(cmd, client, resource_group_name, account_name, action='Allow', subnet=None, - vnet_name=None, ip_address=None, tenant_id=None, resource_id=None): # pylint: disable=unused-argument + vnet_name=None, ip_address=None, tenant_id=None, resource_id=None, + what_if=False, export_bicep=False): # pylint: disable=unused-argument sa = client.get_properties(resource_group_name, account_name) rules = sa.network_rule_set if not subnet and not ip_address: From 6bdefb94c416d50f26167ea186eae60a57da6732 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Tue, 11 Nov 2025 12:36:05 +0800 Subject: [PATCH 22/28] minor fix --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 9 +++++---- src/azure-cli-core/azure/cli/core/what_if.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index b533a396fa3..e3427f3f56c 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -763,10 +763,9 @@ def _what_if(self, args): ) except (CLIError, ValueError, KeyError) as ex: # If what-if service fails, still show an informative message + logger.error("What-if preview failed: %s", str(ex)) return CommandResultItem(None, exit_code=1, - error=CLIError(f'What-if preview failed: {str(ex)}\n' - f'Note: This was a preview operation. ' - f'No actual changes were made.')) + error=CLIError(f'What-if preview failed: {str(ex)}')) def _is_command_supported_for_what_if(self, args): """Check if the command is in the what-if whitelist @@ -789,7 +788,9 @@ def _is_command_supported_for_what_if(self, args): 'storage account network-rule add', 'vm disk attach', 'vm disk detach', - 'vm nic remove' + 'vm nic remove', + 'sql server create', + 'sql server update', } # Extract command parts (skip 'az' and flags) diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py index 4c627aa9f72..01e91854b5e 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -257,7 +257,7 @@ def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pre if no_pretty_print: return result_data - + print(format_what_if_operation_result(what_if_operation_result, cli_ctx.enable_color)) return result_data From 6fd0a35c6e58ff61c441c98046dec37220303f76 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Mon, 17 Nov 2025 14:49:29 +0800 Subject: [PATCH 23/28] resource: update What-If noise notice link to GitHub issues --- src/azure-cli/azure/cli/command_modules/resource/_formatters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/resource/_formatters.py b/src/azure-cli/azure/cli/command_modules/resource/_formatters.py index 36b2bd8c954..e67962a2760 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/_formatters.py +++ b/src/azure-cli/azure/cli/command_modules/resource/_formatters.py @@ -101,7 +101,7 @@ def format_what_if_operation_result(what_if_operation_result, enable_color=True) def _format_noise_notice(builder): builder.append_line( """Note: The result may contain false positive predictions (noise). -You can help us improve the accuracy of the result by opening an issue here: https://aka.ms/WhatIfIssues""" +You can help us improve the accuracy of the result by opening an issue here: https://github.com/Azure/azure-cli/issues""" ) builder.append_line() From 45f3dc96df8c0e9227fe307539f7bee68b37a2b7 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Wed, 19 Nov 2025 11:06:04 +0800 Subject: [PATCH 24/28] resource: update What-If issue link to the new GitHub issue template --- src/azure-cli/azure/cli/command_modules/resource/_formatters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/resource/_formatters.py b/src/azure-cli/azure/cli/command_modules/resource/_formatters.py index e67962a2760..d6246d396d7 100644 --- a/src/azure-cli/azure/cli/command_modules/resource/_formatters.py +++ b/src/azure-cli/azure/cli/command_modules/resource/_formatters.py @@ -101,7 +101,7 @@ def format_what_if_operation_result(what_if_operation_result, enable_color=True) def _format_noise_notice(builder): builder.append_line( """Note: The result may contain false positive predictions (noise). -You can help us improve the accuracy of the result by opening an issue here: https://github.com/Azure/azure-cli/issues""" +You can help us improve the accuracy of the result by opening an issue here: https://github.com/Azure/azure-cli/issues/new?template=what_if.yml""" ) builder.append_line() From d36baf681402bc9b501f0a16337ff19287c0b22d Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Mon, 1 Dec 2025 16:52:01 +0800 Subject: [PATCH 25/28] minor fix --- src/azure-cli-core/azure/cli/core/what_if.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/what_if.py b/src/azure-cli-core/azure/cli/core/what_if.py index 01e91854b5e..4f56e185edf 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -48,7 +48,7 @@ def _make_what_if_request(payload, headers_dict, cli_ctx=None): def _rotating_progress(): """Simulate a rotating progress indicator.""" spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - fallback_chars = ["|", "\\", "/", "-"] + fallback_chars = ["|", "/", "-", "\\"] try: "⠋".encode(sys.stderr.encoding or 'utf-8') @@ -92,17 +92,11 @@ def _rotating_progress(): elapsed_str = f"{BOLD}({elapsed:.0f}s){RESET}" spinner = f"{spinner_color}{chars[idx % len(chars)]}{RESET}" progress_line = f"{spinner} {status}... {elapsed_str}" - visible_length = len(progress_line) - (progress_line.count('\033[') * 5) - max_width = 100 - if visible_length > max_width: - truncated_status = status[:max_width - 30] + "..." - progress_line = f"{spinner} {truncated_status} {elapsed_str}" - sys.stderr.write(f"\r{' ' * 120}\r{progress_line}") + sys.stderr.write(f"\033[2K\r{progress_line}") sys.stderr.flush() idx += 1 time.sleep(0.12) - clear_line = f"\r{' ' * 120}\r" - sys.stderr.write(clear_line) + sys.stderr.write("\033[2K\r") sys.stderr.flush() try: From 252813cb3d8447409f52cc2272e5e0fdc796e466 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Tue, 2 Dec 2025 09:39:51 +0800 Subject: [PATCH 26/28] Add telemetry --- .../azure/cli/core/commands/__init__.py | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index e3427f3f56c..2fee11c3cb7 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -702,20 +702,32 @@ def _what_if(self, args): if '--what-if' in args: logger.debug("Entering what-if mode") + # Remove both --what-if and --export-bicep from args for processing + clean_args = [arg for arg in args if arg not in ['--what-if', '--export-bicep']] + command_parts = [arg for arg in clean_args if not arg.startswith('-') and arg != 'az'] + command_name = ' '.join(command_parts) if command_parts else 'unknown' + safe_params = AzCliCommandInvoker._extract_parameter_names(args) + + # Set command details first so telemetry knows which command was attempted + telemetry.set_command_details( + command_name + ' --what-if', + self.data.get('output', 'json'), + safe_params + ) + # Check if command is in whitelist if not self._is_command_supported_for_what_if(args): error_msg = ("\"--what-if\" argument is not supported for this command.") logger.error(error_msg) + telemetry.set_user_fault(summary='what-if-unsupported-command') return CommandResultItem(None, exit_code=1, error=CLIError(error_msg)) from azure.cli.core.what_if import show_what_if + + # Check if --export-bicep is present + export_bicep = '--export-bicep' in args + try: - # Check if --export-bicep is present - export_bicep = '--export-bicep' in args - - # Remove both --what-if and --export-bicep from args for processing - clean_args = [arg for arg in args if arg not in ['--what-if', '--export-bicep']] - if export_bicep: logger.debug("Export bicep mode enabled") @@ -752,6 +764,8 @@ def _what_if(self, args): # Default to 'json' if not already set if 'output' not in self.cli_ctx.invocation.data or self.cli_ctx.invocation.data['output'] is None: self.cli_ctx.invocation.data['output'] = 'json' + + telemetry.set_success(summary='what-if-completed') # Return the formatted what-if output as the result # Similar to the normal flow in execute() method @@ -764,6 +778,8 @@ def _what_if(self, args): except (CLIError, ValueError, KeyError) as ex: # If what-if service fails, still show an informative message logger.error("What-if preview failed: %s", str(ex)) + telemetry.set_exception(ex, fault_type='what-if-error', summary=str(ex)[:100]) + telemetry.set_failure(summary='what-if-failed') return CommandResultItem(None, exit_code=1, error=CLIError(f'What-if preview failed: {str(ex)}')) From 819cea0263e948cdd131ddb236e958ff3afe1153 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Tue, 2 Dec 2025 10:42:50 +0800 Subject: [PATCH 27/28] minor fix --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 2fee11c3cb7..1832717d619 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -778,7 +778,7 @@ def _what_if(self, args): except (CLIError, ValueError, KeyError) as ex: # If what-if service fails, still show an informative message logger.error("What-if preview failed: %s", str(ex)) - telemetry.set_exception(ex, fault_type='what-if-error', summary=str(ex)[:100]) + telemetry.set_exception(ex, fault_type='what-if-error') telemetry.set_failure(summary='what-if-failed') return CommandResultItem(None, exit_code=1, error=CLIError(f'What-if preview failed: {str(ex)}')) From 9f6ab145c30d87926339d3ba2a7628473be88c14 Mon Sep 17 00:00:00 2001 From: ZelinWang Date: Tue, 2 Dec 2025 12:55:05 +0800 Subject: [PATCH 28/28] minor fix --- .../azure/cli/core/commands/__init__.py | 4 ++++ src/azure-cli-core/azure/cli/core/telemetry.py | 18 ++++++++++++++++++ .../azure/cli/telemetry/__init__.py | 1 + 3 files changed, 23 insertions(+) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 1832717d619..3f251fc5213 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -719,6 +719,7 @@ def _what_if(self, args): if not self._is_command_supported_for_what_if(args): error_msg = ("\"--what-if\" argument is not supported for this command.") logger.error(error_msg) + telemetry.set_what_if_summary('what-if-unsupported-command') telemetry.set_user_fault(summary='what-if-unsupported-command') return CommandResultItem(None, exit_code=1, error=CLIError(error_msg)) @@ -765,6 +766,7 @@ def _what_if(self, args): if 'output' not in self.cli_ctx.invocation.data or self.cli_ctx.invocation.data['output'] is None: self.cli_ctx.invocation.data['output'] = 'json' + telemetry.set_what_if_summary('what-if-completed') telemetry.set_success(summary='what-if-completed') # Return the formatted what-if output as the result @@ -778,6 +780,8 @@ def _what_if(self, args): except (CLIError, ValueError, KeyError) as ex: # If what-if service fails, still show an informative message logger.error("What-if preview failed: %s", str(ex)) + telemetry.set_what_if_summary('what-if-failed') + telemetry.set_what_if_exception(ex) telemetry.set_exception(ex, fault_type='what-if-error') telemetry.set_failure(summary='what-if-failed') return CommandResultItem(None, exit_code=1, diff --git a/src/azure-cli-core/azure/cli/core/telemetry.py b/src/azure-cli-core/azure/cli/core/telemetry.py index 2388002f532..44d4b4f525a 100644 --- a/src/azure-cli-core/azure/cli/core/telemetry.py +++ b/src/azure-cli-core/azure/cli/core/telemetry.py @@ -78,6 +78,9 @@ def __init__(self, correlation_id=None, application=None): self.enable_broker_on_windows = None self.msal_telemetry = None self.login_experience_v2 = None + # what-if specific telemetry + self.what_if_summary = None + self.what_if_exception = None def add_event(self, name, properties): for key in self.instrumentation_key: @@ -234,6 +237,9 @@ def _get_azure_cli_properties(self): set_custom_properties(result, 'EnableBrokerOnWindows', str(self.enable_broker_on_windows)) set_custom_properties(result, 'MsalTelemetry', self.msal_telemetry) set_custom_properties(result, 'LoginExperienceV2', str(self.login_experience_v2)) + # what-if related + set_custom_properties(result, 'WhatIfSummary', self.what_if_summary) + set_custom_properties(result, 'WhatIfException', self.what_if_exception) return result @@ -486,6 +492,18 @@ def set_msal_telemetry(msal_telemetry): @decorators.suppress_all_exceptions() def set_login_experience_v2(login_experience_v2): _session.login_experience_v2 = login_experience_v2 + + +@decorators.suppress_all_exceptions() +def set_what_if_summary(summary): + _session.what_if_summary = summary + + +@decorators.suppress_all_exceptions() +def set_what_if_exception(exception): + # Store exception type and message, limit length to avoid huge payloads + exception_info = f"{exception.__class__.__name__}: {str(exception)[:512]}" + _session.what_if_exception = exception_info # endregion diff --git a/src/azure-cli-telemetry/azure/cli/telemetry/__init__.py b/src/azure-cli-telemetry/azure/cli/telemetry/__init__.py index 628b863d3d6..b38bd7e8515 100644 --- a/src/azure-cli-telemetry/azure/cli/telemetry/__init__.py +++ b/src/azure-cli-telemetry/azure/cli/telemetry/__init__.py @@ -58,6 +58,7 @@ def save(config_dir, payload): events = json.loads(payload) logger.info('Begin splitting cli events and extra events, total events: %s', len(events)) + logger.debug('events: %s', events) cli_events = {} client = CliTelemetryClient() for key, event in events.items():