Skip to content

Commit bf8fb4d

Browse files
committed
Merge remote-tracking branch 'origin' into vm-unmanaged-disk-migration
2 parents 469b539 + 3fbbdfc commit bf8fb4d

File tree

18 files changed

+5990
-3072
lines changed

18 files changed

+5990
-3072
lines changed

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

Lines changed: 202 additions & 97 deletions
Large diffs are not rendered by default.

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

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,22 +1134,17 @@ def _load_command_loader(loader, args, name, prefix):
11341134
logger.debug("Module '%s' is missing `get_command_loader` entry.", name)
11351135

11361136
command_table = {}
1137+
command_loader = None
11371138

11381139
if loader_cls:
11391140
command_loader = loader_cls(cli_ctx=loader.cli_ctx)
1140-
loader.loaders.append(command_loader) # This will be used by interactive
11411141
if command_loader.supported_resource_type():
11421142
command_table = command_loader.load_command_table(args)
1143-
if command_table:
1144-
for cmd in list(command_table.keys()):
1145-
# TODO: If desired to for extension to patch module, this can be uncommented
1146-
# if loader.cmd_to_loader_map.get(cmd):
1147-
# loader.cmd_to_loader_map[cmd].append(command_loader)
1148-
# else:
1149-
loader.cmd_to_loader_map[cmd] = [command_loader]
11501143
else:
11511144
logger.debug("Module '%s' is missing `COMMAND_LOADER_CLS` entry.", name)
1152-
return command_table, command_loader.command_group_table
1145+
1146+
group_table = command_loader.command_group_table if command_loader else {}
1147+
return command_table, group_table, command_loader
11531148

11541149

11551150
def _load_extension_command_loader(loader, args, ext):

src/azure-cli-core/azure/cli/core/tests/test_command_registration.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def load_command_table(self, args):
230230
if command_table:
231231
module_command_table.update(command_table)
232232
loader.loaders.append(command_loader) # this will be used later by the load_arguments method
233-
return module_command_table, command_loader.command_group_table
233+
return module_command_table, command_loader.command_group_table, command_loader
234234

235235
expected_command_index = {'hello': ['azure.cli.command_modules.hello', 'azext_hello2', 'azext_hello1'],
236236
'extra': ['azure.cli.command_modules.extra']}
@@ -260,6 +260,41 @@ def test_register_command_from_extension(self):
260260
self.assertTrue(isinstance(hello_overridden_cmd.command_source, ExtensionCommandSource))
261261
self.assertTrue(hello_overridden_cmd.command_source.overrides_command)
262262

263+
@mock.patch('importlib.import_module', _mock_import_lib)
264+
@mock.patch('pkgutil.iter_modules', _mock_iter_modules)
265+
@mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader)
266+
@mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname)
267+
@mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions)
268+
def test_cmd_to_loader_map_populated_after_parallel_loading(self):
269+
"""
270+
Validates that all commands in command_table have corresponding entries in cmd_to_loader_map.
271+
"""
272+
cli = DummyCli()
273+
loader = cli.commands_loader
274+
275+
# Load all commands (triggers parallel module loading)
276+
cmd_tbl = loader.load_command_table(None)
277+
278+
# Verify EVERY command in command_table has an entry in cmd_to_loader_map
279+
# This is exactly what azdev does before it hits KeyError
280+
for cmd_name in cmd_tbl:
281+
# This should NOT raise KeyError
282+
self.assertIn(cmd_name, loader.cmd_to_loader_map,
283+
f"Command '{cmd_name}' missing from cmd_to_loader_map - "
284+
f"would cause KeyError in azdev command-change meta-export")
285+
286+
# Verify the entry is a list with at least one loader
287+
loaders = loader.cmd_to_loader_map[cmd_name]
288+
self.assertIsInstance(loaders, list,
289+
f"cmd_to_loader_map['{cmd_name}'] should be a list")
290+
self.assertGreater(len(loaders), 0,
291+
f"cmd_to_loader_map['{cmd_name}'] should have at least one loader")
292+
293+
# Verify all expected commands are present
294+
expected_commands = {'hello mod-only', 'hello overridden', 'extra final', 'hello ext-only'}
295+
actual_commands = set(cmd_tbl.keys())
296+
self.assertEqual(expected_commands, actual_commands)
297+
263298
@mock.patch('importlib.import_module', _mock_import_lib)
264299
@mock.patch('pkgutil.iter_modules', _mock_iter_modules)
265300
@mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader)
@@ -432,12 +467,12 @@ def test_command_index_positional_argument(self):
432467
# Test command index is built for command with positional argument
433468
cmd_tbl = loader.load_command_table(["extra", "extra", "positional_argument"])
434469
self.assertDictEqual(INDEX[CommandIndex._COMMAND_INDEX], self.expected_command_index)
435-
self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden', 'extra final', 'hello ext-only'])
470+
self.assertSetEqual(set(cmd_tbl), {'hello mod-only', 'hello overridden', 'extra final', 'hello ext-only'})
436471

437472
# Test command index is used by command with positional argument
438473
cmd_tbl = loader.load_command_table(["hello", "mod-only", "positional_argument"])
439474
self.assertDictEqual(INDEX[CommandIndex._COMMAND_INDEX], self.expected_command_index)
440-
self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden', 'hello ext-only'])
475+
self.assertSetEqual(set(cmd_tbl), {'hello mod-only', 'hello overridden', 'hello ext-only'})
441476

442477
# Test command index is used by command with positional argument
443478
cmd_tbl = loader.load_command_table(["extra", "final", "positional_argument2"])
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
import unittest
7+
8+
from azure.cli.core.mock import DummyCli
9+
from azure.cli.core import MainCommandsLoader
10+
11+
12+
class CommandTableIntegrityTest(unittest.TestCase):
13+
14+
def setUp(self):
15+
self.cli_ctx = DummyCli()
16+
17+
def test_command_table_integrity(self):
18+
"""Test command table loading produces valid, complete results."""
19+
20+
# Load command table using current implementation
21+
loader = MainCommandsLoader(self.cli_ctx)
22+
loader.load_command_table([])
23+
24+
# Test invariants that should always hold:
25+
26+
# 1. No corruption/duplicates
27+
command_names = list(loader.command_table.keys())
28+
unique_command_names = set(command_names)
29+
self.assertEqual(len(unique_command_names), len(command_names), "No duplicate commands")
30+
31+
# 2. Core functionality exists (high-level groups that should always exist)
32+
core_groups = ['vm', 'network', 'resource', 'account', 'group']
33+
existing_groups = {cmd.split()[0] for cmd in loader.command_table.keys() if ' ' in cmd}
34+
missing_core = [group for group in core_groups if group not in existing_groups]
35+
self.assertEqual(len(missing_core), 0, f"Missing core command groups: {missing_core}")
36+
37+
# 3. Structural integrity
38+
commands_without_source = []
39+
for cmd_name, cmd_obj in loader.command_table.items():
40+
if not hasattr(cmd_obj, 'command_source') or not cmd_obj.command_source:
41+
commands_without_source.append(cmd_name)
42+
43+
self.assertEqual(len(commands_without_source), 0,
44+
f"Commands missing source: {commands_without_source[:5]}...")
45+
46+
# 4. Basic sanity - we loaded SOMETHING
47+
self.assertGreater(len(loader.command_table), 0, "Commands were loaded")
48+
self.assertGreater(len(loader.command_group_table), 0, "Groups were loaded")
49+
50+
# 5. Verify core groups are properly represented
51+
found_core_groups = sorted(existing_groups & set(core_groups))
52+
self.assertGreaterEqual(len(found_core_groups), 3,
53+
f"At least 3 core command groups should be present, found: {found_core_groups}")
54+
55+
56+
if __name__ == '__main__':
57+
unittest.main()

src/azure-cli-core/azure/cli/core/tests/test_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def load_command_table(self, args):
188188
if command_table:
189189
module_command_table.update(command_table)
190190
loader.loaders.append(command_loader) # this will be used later by the load_arguments method
191-
return module_command_table, command_loader.command_group_table
191+
return module_command_table, command_loader.command_group_table, command_loader
192192

193193
@mock.patch('importlib.import_module', _mock_import_lib)
194194
@mock.patch('pkgutil.iter_modules', _mock_iter_modules)

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -843,19 +843,29 @@ def flexible_server_restore(cmd, client, resource_group_name, server_name, sourc
843843
else:
844844
parameters.network = source_server_object.network
845845

846-
except Exception as e:
847-
raise ResourceNotFoundError(e)
848-
849-
resolve_poller(
850-
client.begin_create(resource_group_name, server_name, parameters), cmd.cli_ctx,
851-
'Restore Server')
846+
except HttpResponseError as exc:
847+
raise ResourceNotFoundError(exc) from exc
848+
849+
def _begin_network_update():
850+
restore_server_object = client.get(resource_group_name, server_name)
851+
restore_server_network = restore_server_object.network
852+
restore_server_network.public_network_access = public_access if public_access else source_server_object.network.public_network_access
853+
update_parameter = models.ServerForUpdate(network=restore_server_network)
854+
return client.begin_update(resource_group_name, server_name, update_parameter)
855+
856+
create_poller = sdk_no_wait(no_wait, client.begin_create, resource_group_name, server_name, parameters)
857+
if no_wait:
858+
def _post_create_update(poller):
859+
try:
860+
_begin_network_update()
861+
except (HttpResponseError, CLIError) as ex:
862+
logger.warning('Skipping post-restore network update: %s', ex)
852863

853-
restore_server_object = client.get(resource_group_name, server_name)
854-
restore_server_network = restore_server_object.network
855-
restore_server_network.public_network_access = public_access if public_access else source_server_object.network.public_network_access
856-
update_parameter = models.ServerForUpdate(network=restore_server_network)
864+
create_poller.add_done_callback(_post_create_update)
865+
return create_poller
857866

858-
return sdk_no_wait(no_wait, client.begin_update, resource_group_name, server_name, update_parameter)
867+
resolve_poller(create_poller, cmd.cli_ctx, 'Restore Server')
868+
return sdk_no_wait(no_wait, _begin_network_update)
859869

860870

861871
# pylint: disable=too-many-locals, too-many-statements, raise-missing-from

src/azure-cli/azure/cli/command_modules/mysql/tests/latest/recordings/test_mysql_flexible_server_restore_no_wait_mgmt.yaml

Lines changed: 2609 additions & 0 deletions
Large diffs are not rendered by default.

src/azure-cli/azure/cli/command_modules/mysql/tests/latest/test_mysql_scenario.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ def test_mysql_flexible_server_georestore_mgmt(self, resource_group):
120120
def test_mysql_flexible_server_georestore_update_mgmt(self, resource_group):
121121
self._test_flexible_server_georestore_update_mgmt('mysql', resource_group)
122122

123+
@AllowLargeResponse()
124+
@ResourceGroupPreparer(location=DEFAULT_LOCATION)
125+
@live_only()
126+
def test_mysql_flexible_server_restore_no_wait_mgmt(self, resource_group):
127+
self._test_flexible_server_restore_no_wait('mysql', resource_group)
128+
123129
@AllowLargeResponse()
124130
@ResourceGroupPreparer(location=DEFAULT_LOCATION)
125131
def test_mysql_flexible_server_gtid_reset(self, resource_group):
@@ -698,6 +704,23 @@ def _test_flexible_server_georestore_update_mgmt(self, database_engine, resource
698704
self.cmd('{} flexible-server delete -g {} -n {} --yes'.format(database_engine, resource_group, source_server))
699705
self.cmd('{} flexible-server delete -g {} -n {} --yes'.format(database_engine, resource_group, target_server))
700706

707+
def _test_flexible_server_restore_no_wait(self, database_engine, resource_group):
708+
location = DEFAULT_LOCATION
709+
source_server = self.create_random_name(SERVER_NAME_PREFIX, SERVER_NAME_MAX_LENGTH)
710+
target_server = self.create_random_name(SERVER_NAME_PREFIX, SERVER_NAME_MAX_LENGTH)
711+
712+
self.cmd('{} flexible-server create -g {} -n {} -l {} --public-access None --tier GeneralPurpose --sku-name {}'
713+
.format(database_engine, resource_group, source_server, location, DEFAULT_GENERAL_PURPOSE_SKU))
714+
715+
self.cmd('{} flexible-server restore -g {} --name {} --source-server {} --no-wait'
716+
.format(database_engine, resource_group, target_server, source_server))
717+
718+
self.cmd('{} flexible-server wait -g {} -n {} --created --interval 30 --timeout 600'
719+
.format(database_engine, resource_group, target_server))
720+
721+
self.cmd('{} flexible-server delete -g {} -n {} --yes'.format(database_engine, resource_group, source_server))
722+
self.cmd('{} flexible-server delete -g {} -n {} --yes'.format(database_engine, resource_group, target_server))
723+
701724

702725
def _test_flexible_server_byok_mgmt(self, database_engine, resource_group, vault_name, backup_vault_name=None):
703726
key_name = self.create_random_name('rdbmskey', 32)

src/azure-cli/azure/cli/command_modules/storage/operations/fileshare.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,18 @@ def _get_client(client, kwargs):
119119
dir_client = client.get_directory_client(directory_path=total_path)
120120
exists = False
121121
from azure.core.exceptions import ClientAuthenticationError
122+
from azure.core.exceptions import ResourceExistsError
122123
try:
123124
exists = dir_client.exists()
124125
except ClientAuthenticationError:
125126
exists = False
127+
except ResourceExistsError as ex:
128+
if hasattr(ex, "error_code") and ex.error_code == "DeletePending":
129+
# translate delete pending flag as file/dir not exists.
130+
exists = False
131+
else:
132+
raise ex
133+
126134
if not exists:
127135
dir_client = client.get_directory_client(directory_path=directory_path)
128136
client = dir_client.get_file_client(file_name=file_name)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,15 @@ def _validate_generation_version_and_trusted_launch(cmd, namespace):
14521452
namespace.os_offer, namespace.os_sku)
14531453
vm_image_info = client.get(namespace.location, namespace.os_publisher, namespace.os_offer,
14541454
namespace.os_sku, os_version)
1455+
1456+
if vm_image_info.image_deprecation_status.image_state == 'ScheduledForDeprecation':
1457+
logger.warning(
1458+
'Warning: This image %s is scheduled for deprecation and will be blocked after %s.\n'
1459+
'VM / VMSS creation is allowed temporarily, but future deployments, redeployments, or '
1460+
'scale‑out operations may fail after this date.\n'
1461+
'Consider switching to a supported image now.', namespace.image,
1462+
vm_image_info.image_deprecation_status.scheduled_deprecation_time.strftime("%B %d, %Y"))
1463+
14551464
generation_version = vm_image_info.hyper_v_generation if hasattr(vm_image_info,
14561465
'hyper_v_generation') else None
14571466
features = vm_image_info.features if hasattr(vm_image_info, 'features') and vm_image_info.features else []

0 commit comments

Comments
 (0)