diff --git a/src/migrate/HISTORY.rst b/src/migrate/HISTORY.rst index e159abb3024..19d5e0d478a 100644 --- a/src/migrate/HISTORY.rst +++ b/src/migrate/HISTORY.rst @@ -2,6 +2,9 @@ Release History =============== +3.0.0b4 ++++++++++++++++ +* Fix edge case bugs in az migrate local replication init & new commands. 3.0.0b3 +++++++++++++++ diff --git a/src/migrate/azext_migrate/custom.py b/src/migrate/azext_migrate/custom.py index b84908e694b..dc2069836f0 100644 --- a/src/migrate/azext_migrate/custom.py +++ b/src/migrate/azext_migrate/custom.py @@ -172,7 +172,7 @@ def new_local_server_replication(cmd, subscription_id = get_subscription_id(cmd.cli_ctx) print(f"Selected Subscription Id: '{subscription_id}'") - rg_uri, machine_id, subscription_id = validate_server_parameters( + rg_uri, machine_id, subscription_id, project_name = validate_server_parameters( cmd, machine_id, machine_index, @@ -239,7 +239,7 @@ def new_local_server_replication(cmd, "Please verify your appliance setup and provided " "-machine_id.") - amh_solution, migrate_project, machine_props = process_amh_solution( + amh_solution, migrate_project, machine_props, project_name = process_amh_solution( cmd, machine, site_object, diff --git a/src/migrate/azext_migrate/helpers/replication/init/_setup_extension.py b/src/migrate/azext_migrate/helpers/replication/init/_setup_extension.py index b21a6995c41..97e4f07e571 100644 --- a/src/migrate/azext_migrate/helpers/replication/init/_setup_extension.py +++ b/src/migrate/azext_migrate/helpers/replication/init/_setup_extension.py @@ -260,7 +260,8 @@ def build_extension_body(instance_type, source_fabric_id, def _wait_for_extension_creation(cmd, extension_uri): - """Wait for extension creation to complete.""" + """Wait for extension creation to complete. Returns final state.""" + ext_state = None for i in range(20): time.sleep(30) try: @@ -277,6 +278,7 @@ def _wait_for_extension_creation(cmd, extension_uri): break except CLIError: print(f"Waiting for extension... ({i + 1}/20)") + return ext_state def _handle_extension_creation_error(cmd, extension_uri, create_error): @@ -315,7 +317,20 @@ def create_replication_extension(cmd, extension_uri, extension_body): print("Extension creation initiated successfully") # Wait for the extension to be created print("Waiting for extension creation to complete...") - _wait_for_extension_creation(cmd, extension_uri) + ext_state = _wait_for_extension_creation(cmd, extension_uri) + if ext_state == ProvisioningState.Failed.value: + raise CLIError( + "Replication extension creation failed. " + "Check the extension resource in the Azure portal " + "for detailed error information.") + if ext_state == ProvisioningState.Canceled.value: + raise CLIError( + "Replication extension creation was canceled.") + if ext_state is None: + raise CLIError( + "Replication extension creation timed out after " + "10 minutes. Check the extension status in the " + "Azure portal.") except CLIError as create_error: _handle_extension_creation_error(cmd, extension_uri, create_error) diff --git a/src/migrate/azext_migrate/helpers/replication/init/_setup_policy.py b/src/migrate/azext_migrate/helpers/replication/init/_setup_policy.py index 8fac96bb2d4..0ecf308db07 100644 --- a/src/migrate/azext_migrate/helpers/replication/init/_setup_policy.py +++ b/src/migrate/azext_migrate/helpers/replication/init/_setup_policy.py @@ -101,13 +101,22 @@ def find_fabric(all_fabrics, appliance_name, fabric_instance_type, }) if is_succeeded and is_correct_instance and name_matches: - # If solution doesn't match, log warning but still consider it - if not is_correct_solution: - logger.warning( - "Fabric '%s' matches name and type but has " - "different solution ID", fabric_name) - fabric = candidate - break + if is_correct_solution: + # Perfect match - use it immediately + fabric = candidate + break + # Name/type match but wrong solution ID - keep as fallback + if not fabric: + fabric = candidate + + if fabric: + fabric_props = fabric.get('properties', {}).get('customProperties', {}) + fabric_sol_id = fabric_props.get('migrationSolutionId', '').rstrip('/') + expected_sol_id = amh_solution.get('id', '').rstrip('/') + if fabric_sol_id.lower() != expected_sol_id.lower(): + logger.warning( + "Fabric '%s' matches name and type but has " + "different solution ID", fabric.get('name')) if not fabric: appliance_type_label = "source" if is_source else "target" @@ -170,6 +179,7 @@ def find_fabric(all_fabrics, appliance_name, fabric_instance_type, def get_fabric_agent(cmd, replication_fabrics_uri, fabric, appliance_name, fabric_instance_type): """Get and validate fabric agent (DRA) for the given fabric.""" + logger = get_logger(__name__) fabric_name = fabric.get('name') dras_uri = ( f"{replication_fabrics_uri}/{fabric_name}" @@ -180,18 +190,59 @@ def get_fabric_agent(cmd, replication_fabrics_uri, fabric, appliance_name, dras = dras_response.json().get('value', []) dra = None + found_but_not_responsive = None for candidate in dras: props = candidate.get('properties', {}) custom_props = props.get('customProperties', {}) - if (props.get('machineName') == appliance_name and - custom_props.get('instanceType') == fabric_instance_type and - bool(props.get('isResponsive'))): - dra = candidate - break + machine_name = props.get('machineName', '') + if (machine_name.lower() == appliance_name.lower() and + custom_props.get('instanceType') == fabric_instance_type): + if bool(props.get('isResponsive')): + dra = candidate + break + found_but_not_responsive = candidate + + # Accept a non-responsive DRA if it's the only match and is provisioned + if not dra and found_but_not_responsive: + nr_props = found_but_not_responsive.get('properties', {}) + last_heartbeat = nr_props.get('lastHeartbeat', 'unknown') + if (nr_props.get('provisioningState') == + ProvisioningState.Succeeded.value): + logger.warning( + "The appliance '%s' DRA is not responsive " + "(last heartbeat: %s). Proceeding since provisioning " + "state is 'Succeeded'.", + appliance_name, last_heartbeat) + dra = found_but_not_responsive + else: + raise CLIError( + f"The appliance '{appliance_name}' is in a " + f"disconnected state (last heartbeat: {last_heartbeat}, " + f"provisioningState: " + f"{nr_props.get('provisioningState')})." + ) if not dra: + # Log available DRAs for diagnostics + if dras: + logger.warning( + "No matching fabric agent found for appliance '%s' " + "(expected instanceType '%s'). Available agents:", + appliance_name, fabric_instance_type) + for candidate in dras: + props = candidate.get('properties', {}) + custom_props = props.get('customProperties', {}) + logger.warning( + " - machineName: '%s', instanceType: '%s', " + "isResponsive: %s", + props.get('machineName'), + custom_props.get('instanceType'), + props.get('isResponsive')) + raise CLIError( - f"The appliance '{appliance_name}' is in a disconnected state." + f"No fabric agent found for appliance '{appliance_name}' " + f"on fabric '{fabric_name}'. Verify that the appliance is " + f"properly registered and connected." ) return dra diff --git a/src/migrate/azext_migrate/helpers/replication/new/_process_inputs.py b/src/migrate/azext_migrate/helpers/replication/new/_process_inputs.py index e2c2f2d463f..b0f6a9ec9fb 100644 --- a/src/migrate/azext_migrate/helpers/replication/new/_process_inputs.py +++ b/src/migrate/azext_migrate/helpers/replication/new/_process_inputs.py @@ -228,7 +228,7 @@ def process_amh_solution(cmd, f"'{resource_group_name}' and project '{project_name}'. " "Please verify your appliance setup." ) - return amh_solution, migrate_project, machine_props + return amh_solution, migrate_project, machine_props, project_name def process_replication_vault(cmd, @@ -482,15 +482,23 @@ def _process_source_fabrics(all_fabrics, }) if is_succeeded and is_correct_instance and name_matches: - # If solution doesn't match, log warning but still consider it - if not is_correct_solution: - logger.warning( - "Fabric '%s' matches name and type but has different " - "solution ID", - fabric_name - ) - source_fabric = fabric - break + if is_correct_solution: + source_fabric = fabric + break + if not source_fabric: + source_fabric = fabric + + if source_fabric: + sf_props = source_fabric.get('properties', {}).get( + 'customProperties', {}) + sf_sol_id = sf_props.get('migrationSolutionId', '').rstrip('/') + exp_sol_id = amh_solution.get('id', '').rstrip('/') + if sf_sol_id.lower() != exp_sol_id.lower(): + logger.warning( + "Fabric '%s' matches name and type but has different " + "solution ID", + source_fabric.get('name')) + return source_fabric, source_fabric_candidates @@ -679,12 +687,22 @@ def _process_target_fabrics(all_fabrics, }) if is_succeeded and is_correct_instance and name_matches: - if not is_correct_solution: - logger.warning( - "Fabric '%s' matches name and type but has different " - "solution ID", fabric_name) - target_fabric = fabric - break + if is_correct_solution: + target_fabric = fabric + break + if not target_fabric: + target_fabric = fabric + + if target_fabric: + tf_props = target_fabric.get('properties', {}).get( + 'customProperties', {}) + tf_sol_id = tf_props.get('migrationSolutionId', '').rstrip('/') + exp_sol_id = amh_solution.get('id', '').rstrip('/') + if tf_sol_id.lower() != exp_sol_id.lower(): + logger.warning( + "Fabric '%s' matches name and type but has different " + "solution ID", target_fabric.get('name')) + return target_fabric, target_fabric_candidates, \ target_fabric_instance_type @@ -731,28 +749,48 @@ def process_target_fabric(cmd, amh_solution): # Get source fabric agent (DRA) source_fabric_name = source_fabric.get('name') - dras_uri = ( + source_dras = send_get_request( + cmd, f"{rg_uri}/providers/Microsoft.DataReplication" f"/replicationFabrics/{source_fabric_name}/fabricAgents" f"?api-version={APIVersion.Microsoft_DataReplication.value}" - ) - source_dras_response = send_get_request(cmd, dras_uri) - source_dras = source_dras_response.json().get('value', []) + ).json().get('value', []) source_dra = None + source_found_not_responsive = None for dra in source_dras: props = dra.get('properties', {}) custom_props = props.get('customProperties', {}) - if (props.get('machineName') == source_appliance_name and - custom_props.get('instanceType') == fabric_instance_type and - bool(props.get('isResponsive'))): - source_dra = dra - break + machine_name = props.get('machineName', '') + if (machine_name.lower() == source_appliance_name.lower() and + custom_props.get('instanceType') == fabric_instance_type): + if bool(props.get('isResponsive')): + source_dra = dra + break + source_found_not_responsive = dra + + if not source_dra and source_found_not_responsive: + nr_props = source_found_not_responsive.get('properties', {}) + last_hb = nr_props.get('lastHeartbeat', 'unknown') + if (nr_props.get('provisioningState') == + ProvisioningState.Succeeded.value): + logger.warning( + "The source appliance '%s' DRA is not responsive " + "(last heartbeat: %s). Proceeding since provisioning " + "state is 'Succeeded'.", + source_appliance_name, last_hb) + source_dra = source_found_not_responsive + else: + raise CLIError( + f"The source appliance '{source_appliance_name}' is in a " + f"disconnected state (last heartbeat: {last_hb}).") if not source_dra: raise CLIError( - f"The source appliance '{source_appliance_name}' is in a " - f"disconnected state.") + f"No fabric agent found for source appliance " + f"'{source_appliance_name}' on fabric " + f"'{source_fabric_name}'. Verify that the appliance is " + f"properly registered and connected.") target_fabric, target_fabric_candidates, \ target_fabric_instance_type = _process_target_fabrics( @@ -769,28 +807,48 @@ def process_target_fabric(cmd, # Get target fabric agent (DRA) target_fabric_name = target_fabric.get('name') - target_dras_uri = ( + target_dras = send_get_request( + cmd, f"{rg_uri}/providers/Microsoft.DataReplication" f"/replicationFabrics/{target_fabric_name}/fabricAgents" f"?api-version={APIVersion.Microsoft_DataReplication.value}" - ) - target_dras_response = send_get_request(cmd, target_dras_uri) - target_dras = target_dras_response.json().get('value', []) + ).json().get('value', []) target_dra = None + target_found_not_responsive = None for dra in target_dras: props = dra.get('properties', {}) custom_props = props.get('customProperties', {}) - if (props.get('machineName') == target_appliance_name and + machine_name = props.get('machineName', '') + if (machine_name.lower() == target_appliance_name.lower() and custom_props.get('instanceType') == - target_fabric_instance_type and - bool(props.get('isResponsive'))): - target_dra = dra - break + target_fabric_instance_type): + if bool(props.get('isResponsive')): + target_dra = dra + break + target_found_not_responsive = dra + + if not target_dra and target_found_not_responsive: + nr_props = target_found_not_responsive.get('properties', {}) + last_hb = nr_props.get('lastHeartbeat', 'unknown') + if (nr_props.get('provisioningState') == + ProvisioningState.Succeeded.value): + logger.warning( + "The target appliance '%s' DRA is not responsive " + "(last heartbeat: %s). Proceeding since provisioning " + "state is 'Succeeded'.", + target_appliance_name, last_hb) + target_dra = target_found_not_responsive + else: + raise CLIError( + f"The target appliance '{target_appliance_name}' is in a " + f"disconnected state (last heartbeat: {last_hb}).") if not target_dra: raise CLIError( - f"The target appliance '{target_appliance_name}' is in a " - f"disconnected state.") + f"No fabric agent found for target appliance " + f"'{target_appliance_name}' on fabric " + f"'{target_fabric_name}'. Verify that the appliance is " + f"properly registered and connected.") return target_fabric, source_dra, target_dra diff --git a/src/migrate/azext_migrate/helpers/replication/new/_validate.py b/src/migrate/azext_migrate/helpers/replication/new/_validate.py index 8a8a2627898..09e8748b1e6 100644 --- a/src/migrate/azext_migrate/helpers/replication/new/_validate.py +++ b/src/migrate/azext_migrate/helpers/replication/new/_validate.py @@ -206,6 +206,11 @@ def validate_server_parameters( # pylint: disable=too-many-locals,too-many-bran # machine_id was provided directly # Check if it's in Microsoft.Migrate format and needs to be resolved if "/Microsoft.Migrate/MigrateProjects/" in machine_id or "/Microsoft.Migrate/migrateprojects/" in machine_id: + # Extract project_name from the Microsoft.Migrate machine ID + migrate_id_parts = machine_id.split("/") + if len(migrate_id_parts) >= 9 and not project_name: + project_name = migrate_id_parts[8] + # This is a Migrate Project machine ID, need to resolve to OffAzure machine ID migrate_machine = get_resource_by_id( cmd, machine_id, APIVersion.Microsoft_Migrate.value) @@ -257,7 +262,7 @@ def validate_server_parameters( # pylint: disable=too-many-locals,too-many-bran f"/subscriptions/{subscription_id}/" f"resourceGroups/{resource_group_name}") - return rg_uri, machine_id, subscription_id + return rg_uri, machine_id, subscription_id, project_name def validate_required_parameters(machine_id, diff --git a/src/migrate/azext_migrate/tests/latest/test_migrate_commands.py b/src/migrate/azext_migrate/tests/latest/test_migrate_commands.py index be9127fc22c..7d93a18a8d4 100644 --- a/src/migrate/azext_migrate/tests/latest/test_migrate_commands.py +++ b/src/migrate/azext_migrate/tests/latest/test_migrate_commands.py @@ -3246,7 +3246,7 @@ def test_process_amh_solution_project_from_discovery(self, mock_get_resource): mock_get_resource.side_effect = [mock_project, mock_amh] - amh, project, props = process_amh_solution( + amh, _, _, _ = process_amh_solution( mock_cmd, mock_machine, mock_site, None, 'rg1', 'machine1', '/subscriptions/sub1/resourceGroups/rg1' ) @@ -3385,7 +3385,7 @@ def test_find_fabric_matching_succeeded(self): @mock.patch('azext_migrate.helpers.replication.init._setup_policy.send_get_request') def test_get_fabric_agent_not_responsive(self, mock_get_request): - """Test get_fabric_agent when agent is not responsive.""" + """Test get_fabric_agent when agent is not responsive and not Succeeded.""" from azext_migrate.helpers.replication.init._setup_policy import get_fabric_agent from knack.util import CLIError @@ -3397,6 +3397,8 @@ def test_get_fabric_agent_not_responsive(self, mock_get_request): 'properties': { 'machineName': 'appliance1', 'isResponsive': False, + 'provisioningState': 'Failed', + 'lastHeartbeat': '2026-01-01T00:00:00Z', 'customProperties': { 'instanceType': 'HyperV' } @@ -3413,6 +3415,99 @@ def test_get_fabric_agent_not_responsive(self, mock_get_request): self.assertIn('disconnected state', str(context.exception)) + @mock.patch('azext_migrate.helpers.replication.init._setup_policy.send_get_request') + def test_get_fabric_agent_not_responsive_but_succeeded(self, mock_get_request): + """Test get_fabric_agent proceeds when DRA is not responsive but provisioning succeeded.""" + from azext_migrate.helpers.replication.init._setup_policy import get_fabric_agent + + mock_cmd = mock.Mock() + mock_response = mock.Mock() + mock_response.json.return_value = { + 'value': [ + { + 'properties': { + 'machineName': 'appliance1', + 'isResponsive': False, + 'provisioningState': 'Succeeded', + 'lastHeartbeat': '2026-03-05T21:46:47Z', + 'customProperties': { + 'instanceType': 'HyperV' + } + } + } + ] + } + mock_get_request.return_value = mock_response + + fabric = {'name': 'fabric1'} + + # Should succeed with a warning, not raise + result = get_fabric_agent( + mock_cmd, '/fabrics', fabric, 'appliance1', 'HyperV') + self.assertEqual( + result['properties']['machineName'], 'appliance1') + + @mock.patch('azext_migrate.helpers.replication.init._setup_policy.send_get_request') + def test_get_fabric_agent_case_insensitive_match(self, mock_get_request): + """Test get_fabric_agent matches machineName case-insensitively.""" + from azext_migrate.helpers.replication.init._setup_policy import get_fabric_agent + + mock_cmd = mock.Mock() + mock_response = mock.Mock() + mock_response.json.return_value = { + 'value': [ + { + 'properties': { + 'machineName': 'Appliance1', + 'isResponsive': True, + 'customProperties': { + 'instanceType': 'HyperV' + } + } + } + ] + } + mock_get_request.return_value = mock_response + + fabric = {'name': 'fabric1'} + + # Should succeed despite case difference + result = get_fabric_agent( + mock_cmd, '/fabrics', fabric, 'appliance1', 'HyperV') + self.assertEqual( + result['properties']['machineName'], 'Appliance1') + + @mock.patch('azext_migrate.helpers.replication.init._setup_policy.send_get_request') + def test_get_fabric_agent_no_matching_agent(self, mock_get_request): + """Test get_fabric_agent when no agent matches the appliance name.""" + from azext_migrate.helpers.replication.init._setup_policy import get_fabric_agent + from knack.util import CLIError + + mock_cmd = mock.Mock() + mock_response = mock.Mock() + mock_response.json.return_value = { + 'value': [ + { + 'properties': { + 'machineName': 'other-appliance', + 'isResponsive': True, + 'customProperties': { + 'instanceType': 'HyperV' + } + } + } + ] + } + mock_get_request.return_value = mock_response + + fabric = {'name': 'fabric1'} + + with self.assertRaises(CLIError) as context: + get_fabric_agent( + mock_cmd, '/fabrics', fabric, 'appliance1', 'HyperV') + + self.assertIn('No fabric agent found', str(context.exception)) + class MigrateNewExecuteTests2(unittest.TestCase): """Additional test class for new/_execute_new.py functions.""" diff --git a/src/migrate/setup.py b/src/migrate/setup.py index 961f84010e9..45fdc92f64d 100644 --- a/src/migrate/setup.py +++ b/src/migrate/setup.py @@ -7,7 +7,7 @@ from setuptools import setup, find_packages -VERSION = "3.0.0b3" +VERSION = "3.0.0b4" CLASSIFIERS = [ 'Development Status :: 4 - Beta',