From abb2f172d5319aa65f13288f908d2ec53b9631ac Mon Sep 17 00:00:00 2001 From: MoChilia Date: Thu, 11 Sep 2025 17:03:14 +0800 Subject: [PATCH 01/18] 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/18] 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 f126b422c12934730b90d8a4ff898e8b6523a8e0 Mon Sep 17 00:00:00 2001 From: MoChilia Date: Thu, 18 Sep 2025 17:19:13 +0800 Subject: [PATCH 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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 499e4779e9a2af36f5252093b6ddc7515a7ba81e Mon Sep 17 00:00:00 2001 From: MoChilia Date: Mon, 22 Sep 2025 15:46:38 +0800 Subject: [PATCH 10/18] 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 b9aac2d4d833909513899c409e08450460595c9f Mon Sep 17 00:00:00 2001 From: Shiying Chen Date: Mon, 22 Sep 2025 16:07:23 +0800 Subject: [PATCH 11/18] Wzl/Support `--what-if` argument (#2) * Add what if feature * Update src/azure-cli-core/azure/cli/core/commands/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * minor fix * Update what_if.py --------- Co-authored-by: ZelinWang Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../azure/cli/core/commands/__init__.py | 44 ++++++++++++++++++- .../azure/cli/core/commands/parameters.py | 10 +++++ src/azure-cli-core/azure/cli/core/what_if.py | 3 +- .../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, 62 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 fb2a9a3dece..c5a13b9b0b1 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,45 @@ 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 show_what_if + 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: + 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) + + # 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(f'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..c165dac37ba 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,16 @@ 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.", + is_preview=True + ) + 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 index 49e6ea07309..d3d665a7a0d 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -3,6 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- + import threading import time import sys @@ -164,4 +165,4 @@ def _create_resource_change(change_data): 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 + return WhatIfOperationResult(changes, potential_changes, []) 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..7b74542656b 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()) # 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 5cab1430a5b..85443cacac4 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()) 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") @@ -1058,6 +1059,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()) 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 5036784b50b..ce8b60b4d5f 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/custom.py +++ b/src/azure-cli/azure/cli/command_modules/vm/custom.py @@ -868,7 +868,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 dbb01fc1ebf7a675748ccd56a9cb39a7183595e7 Mon Sep 17 00:00:00 2001 From: MoChilia Date: Mon, 22 Sep 2025 16:56:56 +0800 Subject: [PATCH 12/18] merge support `--what-if` --- src/azure-cli-core/azure/cli/core/what_if.py | 41 ++++++++++++++++--- .../azure/cli/command_modules/util/custom.py | 32 ++------------- .../util/tests/latest/test_whatif_script.sh | 3 +- 3 files changed, 41 insertions(+), 35 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 d3d665a7a0d..d37a425fe61 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,7 +40,7 @@ def get_auth_headers(cmd, subscription_id): } -def make_what_if_request(payload, headers_dict): +def _make_what_if_request(payload, headers_dict): request_completed = threading.Event() def _rotating_progress(): @@ -166,3 +165,35 @@ 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) + + 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, 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/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index b935f82a3b7..95602ff7b23 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -373,35 +373,9 @@ 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.what_if import show_what_if as _show_what_if, read_script_file 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}") + script_content = read_script_file(script_path) + return _show_what_if(cmd.cli_ctx, script_content, subscription_id=subscription_id, no_pretty_print=no_pretty_print) 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 index 124797a2b26..b121a971c94 100644 --- 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 @@ -1,2 +1,3 @@ -# 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 + +az functionapp update --name myfunctionapp --resource-group myrg --set tags.Environment=Test \ No newline at end of file From 53ce56b8332597b6ada359aff82d254b7ee52230 Mon Sep 17 00:00:00 2001 From: MoChilia Date: Tue, 23 Sep 2025 16:05:04 +0800 Subject: [PATCH 13/18] format --- .../azure/cli/core/commands/__init__.py | 14 ++++++++------ src/azure-cli-core/azure/cli/core/what_if.py | 1 + .../azure/cli/command_modules/sql/custom.py | 2 +- .../util/tests/latest/test_whatif.py | 4 ++-- 4 files changed, 12 insertions(+), 9 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 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 d37a425fe61..16ebba26df7 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -166,6 +166,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): from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.command_modules.resource._formatters import format_what_if_operation_result 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/tests/latest/test_whatif.py b/src/azure-cli/azure/cli/command_modules/util/tests/latest/test_whatif.py index c8c9e8cdcda..bc64705a38a 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 @@ -18,8 +18,8 @@ def setUp(self): 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') + @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'} From 5f1096dafc9f9f0b4d2520cd0ebf3b71befeea55 Mon Sep 17 00:00:00 2001 From: MoChilia Date: Tue, 23 Sep 2025 16:39:39 +0800 Subject: [PATCH 14/18] enhanced progress bar --- src/azure-cli-core/azure/cli/core/what_if.py | 60 ++++++++++++++++++-- 1 file changed, 54 insertions(+), 6 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 16ebba26df7..cb7a2c0e4ac 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -40,19 +40,67 @@ def _get_auth_headers(cli_ctx, 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: @@ -180,7 +228,7 @@ def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pre } headers_dict = _get_auth_headers(cli_ctx, subscription_id) - response = _make_what_if_request(payload, headers_dict) + response = _make_what_if_request(payload, headers_dict, cli_ctx) try: raw_results = response.json() From 525939cd56add22288626bf3709b0b31a741ea1d Mon Sep 17 00:00:00 2001 From: MoChilia Date: Mon, 13 Oct 2025 17:17:19 +0800 Subject: [PATCH 15/18] modify error output --- src/azure-cli-core/azure/cli/core/what_if.py | 6 +++--- 1 file changed, 3 insertions(+), 3 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 cb7a2c0e4ac..dc6612b14c6 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -93,7 +93,7 @@ def _rotating_progress(): visible_length = len(progress_line) - (progress_line.count('\033[') * 5) max_width = 100 if visible_length > max_width: - truncated_status = status[:max_width-30] + "..." + 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() @@ -233,11 +233,11 @@ def show_what_if(cli_ctx, azcli_script: str, subscription_id: str = None, no_pre try: raw_results = response.json() except ValueError as ex: - raise CLIError(f"Failed to parse response from what-if service: {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: - return raw_results + 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) From 819531e24ca23717898f5715068431a46b91891a Mon Sep 17 00:00:00 2001 From: MoChilia Date: Wed, 29 Oct 2025 16:37:13 +0800 Subject: [PATCH 16/18] update url --- src/azure-cli-core/azure/cli/core/what_if.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dc6612b14c6..f29a0bd73c5 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -111,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) From 9b1d2541475114e89a05f3e79f2fb4aa12fa8114 Mon Sep 17 00:00:00 2001 From: MoChilia Date: Mon, 1 Dec 2025 14:20:52 +0800 Subject: [PATCH 17/18] Check if stderr supports interactive output --- src/azure-cli-core/azure/cli/core/what_if.py | 6 ++++++ 1 file changed, 6 insertions(+) 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..ed06b432d25 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -45,6 +45,12 @@ def _make_what_if_request(payload, headers_dict, cli_ctx=None): def _rotating_progress(): """Simulate a rotating progress indicator.""" + # Check if stderr supports interactive output + if not sys.stderr.isatty(): + sys.stderr.write("Processing what-if analysis...\n") + sys.stderr.flush() + return + spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] fallback_chars = ["|", "\\", "/", "-"] From a9cfa26f83a494d1c477bbe737029dd2c86340af Mon Sep 17 00:00:00 2001 From: MoChilia Date: Mon, 1 Dec 2025 15:52:35 +0800 Subject: [PATCH 18/18] update --- src/azure-cli-core/azure/cli/core/what_if.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 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 ed06b432d25..1272fff2bea 100644 --- a/src/azure-cli-core/azure/cli/core/what_if.py +++ b/src/azure-cli-core/azure/cli/core/what_if.py @@ -45,14 +45,8 @@ def _make_what_if_request(payload, headers_dict, cli_ctx=None): def _rotating_progress(): """Simulate a rotating progress indicator.""" - # Check if stderr supports interactive output - if not sys.stderr.isatty(): - sys.stderr.write("Processing what-if analysis...\n") - sys.stderr.flush() - return - spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - fallback_chars = ["|", "\\", "/", "-"] + fallback_chars = ["|", "/", "-", "\\"] try: "⠋".encode(sys.stderr.encoding or 'utf-8') @@ -96,17 +90,12 @@ 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: