Skip to content

Commit 7bc0289

Browse files
committed
Add what if feature
1 parent 617ddcf commit 7bc0289

File tree

7 files changed

+167
-3
lines changed

7 files changed

+167
-3
lines changed

src/azure-cli-core/azure/cli/core/commands/__init__.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,8 @@ class AzCliCommandInvoker(CommandInvoker):
506506

507507
# pylint: disable=too-many-statements,too-many-locals,too-many-branches
508508
def execute(self, args):
509+
args_copy = args[:]
510+
509511
from knack.events import (EVENT_INVOKER_PRE_CMD_TBL_CREATE, EVENT_INVOKER_POST_CMD_TBL_CREATE,
510512
EVENT_INVOKER_CMD_TBL_LOADED, EVENT_INVOKER_PRE_PARSE_ARGS,
511513
EVENT_INVOKER_POST_PARSE_ARGS,
@@ -586,7 +588,8 @@ def execute(self, args):
586588
args[0] = '--help'
587589

588590
self.parser.enable_autocomplete()
589-
591+
if '--what-if' in (args_copy):
592+
return self._what_if(args_copy)
590593
self.cli_ctx.raise_event(EVENT_INVOKER_PRE_PARSE_ARGS, args=args)
591594
parsed_args = self.parser.parse_args(args)
592595
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):
691694
table_transformer=self.commands_loader.command_table[parsed_args.command].table_transformer,
692695
is_query_active=self.data['query_active'])
693696

697+
def _what_if(self, args):
698+
# DEBUG: Add logging to see if this method is called
699+
print(f"DEBUG: _what_if called with command: {args}")
700+
if '--what-if' in args:
701+
print("DEBUG: Entering what-if mode")
702+
from azure.cli.core.what_if import what_if_preview
703+
try:
704+
# Get subscription ID with priority: --subscription parameter > current login subscription
705+
if '--subscription' in args:
706+
index = args.index('--subscription')
707+
if index + 1 < len(args):
708+
subscription_value = args[index + 1]
709+
subscription_id = subscription_value
710+
else:
711+
# Fallback to current login subscription TODO
712+
subscription_id = self.cli_ctx.data.get("subscription_id", "6b085460-5f21-477e-ba44-1035046e9101")
713+
714+
args = ["az"] + args if args[0] != 'az' else args
715+
command = " ".join(args)
716+
what_if_result = what_if_preview(command, subscription_id=subscription_id)
717+
718+
# Ensure output format is set for proper formatting
719+
# Default to 'json' if not already set
720+
if 'output' not in self.cli_ctx.invocation.data or self.cli_ctx.invocation.data['output'] is None:
721+
self.cli_ctx.invocation.data['output'] = 'json'
722+
723+
# Return the formatted what-if output as the result
724+
# Similar to the normal flow in execute() method
725+
return CommandResultItem(
726+
what_if_result,
727+
table_transformer=None,
728+
is_query_active=self.data.get('query_active', False),
729+
exit_code=0
730+
)
731+
except Exception as ex:
732+
# If what-if service fails, still show an informative message
733+
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.'))
734+
694735
@staticmethod
695736
def _extract_parameter_names(args):
696737
# note: name start with more than 2 '-' will be treated as value e.g. certs in PEM format

src/azure-cli-core/azure/cli/core/commands/parameters.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,15 @@ def get_location_type(cli_ctx):
268268
return location_type
269269

270270

271+
def get_what_if_type():
272+
what_if_type = CLIArgumentType(
273+
options_list=['--what-if'],
274+
help="Preview the changes that will be made without actually executing the command. "
275+
"This will call the what-if service to compare the current state with the expected state after execution."
276+
)
277+
return what_if_type
278+
279+
271280
deployment_name_type = CLIArgumentType(
272281
help=argparse.SUPPRESS,
273282
required=False,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
"""
7+
Module for handling what-if functionality in Azure CLI.
8+
This module provides the core logic for preview mode execution without actually running commands.
9+
10+
IMPORTANT: The what-if service requires client-side authentication to operate under the
11+
caller's subscription and permissions. Server-side authentication is not supported for
12+
what-if operations as it would not provide access to the caller's subscription.
13+
14+
This client now uses AzureCliCredential to obtain an access token for the caller's subscription.
15+
16+
The what-if service will use your configured credentials to access your subscription
17+
and preview deployment changes under your permissions.
18+
"""
19+
20+
import requests
21+
from typing import Dict, Any, Optional
22+
from azure.identity import AzureCliCredential
23+
from datetime import datetime, timezone
24+
from knack.log import get_logger
25+
26+
logger = get_logger(__name__)
27+
28+
# Configuration
29+
FUNCTION_APP_URL = "https://azcli-script-insight.azurewebsites.net"
30+
31+
32+
def get_azure_cli_access_token() -> Optional[str]:
33+
"""
34+
Get access token for the caller's subscription using AzureCliCredential
35+
36+
Returns:
37+
Access token string if successful, None if failed
38+
"""
39+
token_info = get_azure_cli_token_info()
40+
return token_info.get("accessToken") if token_info else None
41+
42+
43+
def get_azure_cli_token_info() -> Optional[Dict[str, Any]]:
44+
"""
45+
Get complete token information using AzureCliCredential including expiration
46+
47+
Returns:
48+
Dictionary with token info including accessToken, expiresOn, etc., or None if failed
49+
"""
50+
try:
51+
# Use AzureCliCredential for Azure CLI authentication
52+
cli_credential = AzureCliCredential(process_timeout=30)
53+
54+
# Get access token for Azure Resource Manager
55+
token = cli_credential.get_token("https://management.azure.com/.default")
56+
57+
token_info = {
58+
"accessToken": token.token,
59+
"expiresOn": datetime.fromtimestamp(token.expires_on, tz=timezone.utc).isoformat(),
60+
"tokenType": "Bearer"
61+
}
62+
63+
return token_info
64+
65+
except Exception as e:
66+
logger.warning(f"Error getting access token with AzureCliCredential: {str(e)}")
67+
return None
68+
69+
70+
def what_if_preview(azcli_script: str, subscription_id: Optional[str] = None) -> Dict[str, Any]:
71+
"""
72+
Preview deployment changes using Azure what-if functionality
73+
74+
Args:
75+
function_app_url: Base URL of your Azure Function App
76+
azcli_script: Azure CLI script to analyze
77+
subscription_id: Optional fallback subscription ID if not in script
78+
79+
Returns:
80+
Dictionary with what-if preview result
81+
"""
82+
url = f"{FUNCTION_APP_URL.rstrip('/')}/api/what_if_preview"
83+
84+
headers = {
85+
'Content-Type': 'application/json',
86+
'Accept': 'application/json'
87+
}
88+
89+
# Get access token from Azure CLI
90+
access_token = get_azure_cli_access_token()
91+
if not access_token:
92+
return {
93+
"error": "Failed to get access token from Azure CLI. Please ensure you are logged in with 'az login'",
94+
"details": "The what-if service requires client credentials to access your subscription. Please provide an access token.",
95+
"success": False
96+
}
97+
98+
# Use Authorization header for access token
99+
headers['Authorization'] = f'Bearer {access_token}'
100+
101+
payload = {"azcli_script": azcli_script}
102+
if subscription_id:
103+
payload["subscription_id"] = subscription_id
104+
105+
try:
106+
response = requests.post(url, json=payload, headers=headers, timeout=300)
107+
return response.json()
108+
except requests.RequestException as e:
109+
raise e

src/azure-cli/azure/cli/command_modules/sql/_params.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
get_enum_type,
4141
get_resource_name_completion_list,
4242
get_location_type,
43+
get_what_if_type,
4344
tags_type,
4445
resource_group_name_type
4546
)
@@ -1915,6 +1916,7 @@ def _configure_security_policy_storage_params(arg_ctx):
19151916
with self.argument_context('sql server create') as c:
19161917
c.argument('location',
19171918
arg_type=get_location_type_with_default_from_resource_group(self.cli_ctx))
1919+
c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.')
19181920

19191921
# Create args that will be used to build up the Server object
19201922
create_args_for_complex_type(

src/azure-cli/azure/cli/command_modules/sql/custom.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4369,6 +4369,7 @@ def server_create(
43694369
external_admin_principal_type=None,
43704370
external_admin_sid=None,
43714371
external_admin_name=None,
4372+
what_if=None,
43724373
**kwargs):
43734374
'''
43744375
Creates a server.

src/azure-cli/azure/cli/command_modules/vm/_params.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from azure.cli.core.commands.validators import (
1414
get_default_location_from_resource_group, validate_file_or_dict)
1515
from azure.cli.core.commands.parameters import (
16-
get_location_type, get_resource_name_completion_list, tags_type, get_three_state_flag,
16+
get_location_type, get_what_if_type, get_resource_name_completion_list, tags_type, get_three_state_flag,
1717
file_type, get_enum_type, zone_type, zones_type)
1818
from azure.cli.command_modules.vm._actions import _resource_not_exists
1919
from azure.cli.command_modules.vm._completers import (
@@ -413,6 +413,7 @@ def load_arguments(self, _):
413413
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.')
414414

415415
with self.argument_context('vm update') as c:
416+
c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.')
416417
c.argument('os_disk', min_api='2017-12-01', help="Managed OS disk ID or name to swap to")
417418
c.argument('write_accelerator', nargs='*', min_api='2017-12-01',
418419
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, _):
10621063
for scope in ['vm create', 'vmss create']:
10631064
with self.argument_context(scope) as c:
10641065
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')
1066+
c.argument('what_if', get_what_if_type(), help='Preview the changes that will be made without actually executing the command.')
10651067
c.argument('tags', tags_type)
10661068
c.argument('no_wait', help='Do not wait for the long-running operation to finish.')
10671069
c.argument('validate', options_list=['--validate'], help='Generate and validate the ARM template without creating any resources.', action='store_true')

src/azure-cli/azure/cli/command_modules/vm/custom.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ def create_vm(cmd, vm_name, resource_group_name, image=None, size='Standard_DS1_
850850
enable_user_redeploy_scheduled_events=None, zone_placement_policy=None, include_zones=None,
851851
exclude_zones=None, align_regional_disks_to_vm_zone=None, wire_server_mode=None, imds_mode=None,
852852
wire_server_access_control_profile_reference_id=None, imds_access_control_profile_reference_id=None,
853-
key_incarnation_id=None):
853+
key_incarnation_id=None, what_if=False):
854854

855855
from azure.cli.core.commands.client_factory import get_subscription_id
856856
from azure.cli.core.util import random_string, hash_string

0 commit comments

Comments
 (0)