Skip to content

Commit 9b6e4c0

Browse files
author
Saif Al-Din Ali
committed
Create list protected items command
1 parent c38da1d commit 9b6e4c0

File tree

5 files changed

+365
-0
lines changed

5 files changed

+365
-0
lines changed

src/migrate/azext_migrate/_help.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,55 @@
304304
--os-disk-id "disk-0"
305305
"""
306306

307+
helps['migrate local replication list'] = """
308+
type: command
309+
short-summary: List all protected items (replicating servers) in a project.
310+
long-summary: |
311+
Lists all servers that have replication enabled
312+
in an Azure Migrate project.
313+
This command shows the replication status, health,
314+
and configuration details for each protected server.
315+
316+
The command returns information including:
317+
- Protection state (e.g., Protected, ProtectedReplicating, EnablingFailed)
318+
- Replication health (Normal, Warning, Critical)
319+
- Source machine name and target VM name
320+
- Replication policy name
321+
- Resource IDs (used for remove command)
322+
- Health errors if any
323+
324+
Note: This command uses a preview API version
325+
and may experience breaking changes in future releases.
326+
parameters:
327+
- name: --resource-group -g
328+
short-summary: Resource group containing the Azure Migrate project.
329+
long-summary: >
330+
The name of the resource group where
331+
the Azure Migrate project is located.
332+
- name: --project-name
333+
short-summary: Name of the Azure Migrate project.
334+
long-summary: >
335+
The Azure Migrate project that contains
336+
the replicating servers.
337+
- name: --subscription-id
338+
short-summary: Azure subscription ID.
339+
long-summary: >
340+
The subscription containing the Azure Migrate project.
341+
Uses the default subscription if not specified.
342+
examples:
343+
- name: List all replicating servers in a project
344+
text: |
345+
az migrate local replication list \\
346+
--resource-group myRG \\
347+
--project-name myMigrateProject
348+
- name: List replicating servers with a specific subscription
349+
text: |
350+
az migrate local replication list \\
351+
--resource-group myRG \\
352+
--project-name myMigrateProject \\
353+
--subscription-id 00000000-0000-0000-0000-000000000000
354+
"""
355+
307356
helps['migrate local replication remove'] = """
308357
type: command
309358
short-summary: Stop replication for a migrated server.

src/migrate/azext_migrate/_params.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ def load_arguments(self, _):
184184
required=True)
185185
c.argument('subscription_id', subscription_id_type)
186186

187+
with self.argument_context('migrate local replication list') as c:
188+
c.argument(
189+
'resource_group',
190+
options_list=['--resource-group', '-g'],
191+
help='The name of the resource group where the migrate '
192+
'project is present.',
193+
required=True)
194+
c.argument(
195+
'project_name',
196+
project_name_type,
197+
help='The name of the migrate project.',
198+
required=True)
199+
c.argument('subscription_id', subscription_id_type)
200+
187201
with self.argument_context('migrate local replication remove') as c:
188202
c.argument(
189203
'target_object_id',

src/migrate/azext_migrate/commands.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ def load_command_table(self, _):
1212
with self.command_group('migrate local replication') as g:
1313
g.custom_command('init', 'initialize_replication_infrastructure')
1414
g.custom_command('new', 'new_local_server_replication')
15+
g.custom_command('list', 'list_local_server_replications')
1516
g.custom_command('remove', 'remove_local_server_replication')
1617
g.custom_command('get-job', 'get_local_replication_job')

src/migrate/azext_migrate/custom.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,55 @@ def get_local_replication_job(cmd,
550550
vault_name, format_job_summary)
551551

552552

553+
def list_local_server_replications(cmd,
554+
resource_group=None,
555+
project_name=None,
556+
subscription_id=None):
557+
"""
558+
List all protected items (replicating servers) in an Azure Migrate project.
559+
560+
This cmdlet is based on a preview API version and may experience
561+
breaking changes in future releases.
562+
563+
Args:
564+
cmd: The CLI command context
565+
resource_group (str, optional): The name of the resource group where
566+
the migrate project is present (required)
567+
project_name (str, optional): The name of the migrate project (required)
568+
subscription_id (str, optional): Azure Subscription ID. Uses
569+
current subscription if not provided
570+
571+
Returns:
572+
list: List of protected items with their replication status
573+
574+
Raises:
575+
CLIError: If required parameters are missing or the vault is not found
576+
"""
577+
from azure.cli.core.commands.client_factory import \
578+
get_subscription_id
579+
from azext_migrate.helpers.replication.list._execute_list import (
580+
get_vault_name_from_project,
581+
list_protected_items
582+
)
583+
584+
# Validate required parameters
585+
if not resource_group or not project_name:
586+
raise CLIError(
587+
"Both --resource-group and --project-name are required.")
588+
589+
# Use current subscription if not provided
590+
if not subscription_id:
591+
subscription_id = get_subscription_id(cmd.cli_ctx)
592+
593+
# Get the vault name from the project
594+
vault_name = get_vault_name_from_project(
595+
cmd, resource_group, project_name, subscription_id)
596+
597+
# List all protected items
598+
list_protected_items(
599+
cmd, subscription_id, resource_group, vault_name)
600+
601+
553602
def remove_local_server_replication(cmd,
554603
target_object_id,
555604
force_remove=False,
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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+
Protected item listing utilities for Azure Migrate local replication.
8+
"""
9+
10+
from knack.util import CLIError
11+
from knack.log import get_logger
12+
13+
logger = get_logger(__name__)
14+
15+
16+
def get_vault_name_from_project(cmd, resource_group_name,
17+
project_name, subscription_id):
18+
"""
19+
Get the vault name from the Azure Migrate project solution.
20+
21+
Args:
22+
cmd: The CLI command context
23+
resource_group_name (str): Resource group name
24+
project_name (str): Migrate project name
25+
subscription_id (str): Subscription ID
26+
27+
Returns:
28+
str: The vault name
29+
30+
Raises:
31+
CLIError: If the solution or vault is not found
32+
"""
33+
from azext_migrate.helpers._utils import get_resource_by_id, APIVersion
34+
35+
# Get the migration solution
36+
solution_name = "Servers-Migration-ServerMigration_DataReplication"
37+
solution_uri = (
38+
f"/subscriptions/{subscription_id}/"
39+
f"resourceGroups/{resource_group_name}/"
40+
f"providers/Microsoft.Migrate/migrateProjects/{project_name}/"
41+
f"solutions/{solution_name}"
42+
)
43+
44+
logger.info(
45+
"Retrieving solution '%s' from project '%s'",
46+
solution_name, project_name)
47+
48+
try:
49+
solution = get_resource_by_id(
50+
cmd,
51+
solution_uri,
52+
APIVersion.Microsoft_Migrate.value
53+
)
54+
55+
if not solution:
56+
raise CLIError(
57+
f"Solution '{solution_name}' not found in project "
58+
f"'{project_name}'. Please run 'az migrate local replication "
59+
f"init' to initialize replication infrastructure.")
60+
61+
# Extract vault ID from solution extended details
62+
properties = solution.get('properties', {})
63+
details = properties.get('details', {})
64+
extended_details = details.get('extendedDetails', {})
65+
vault_id = extended_details.get('vaultId')
66+
67+
if not vault_id:
68+
raise CLIError(
69+
"Vault ID not found in solution. The replication "
70+
"infrastructure may not be initialized. Please run "
71+
"'az migrate local replication init'.")
72+
73+
# Parse vault name from vault ID
74+
vault_id_parts = vault_id.split("/")
75+
if len(vault_id_parts) < 9:
76+
raise CLIError(f"Invalid vault ID format: {vault_id}")
77+
78+
vault_name = vault_id_parts[8]
79+
return vault_name
80+
81+
except CLIError:
82+
raise
83+
except Exception as e:
84+
logger.error(
85+
"Error retrieving vault from project '%s': %s",
86+
project_name, str(e))
87+
raise CLIError(
88+
f"Failed to retrieve vault information: {str(e)}")
89+
90+
91+
def list_protected_items(cmd, subscription_id, resource_group_name, vault_name):
92+
"""
93+
List all protected items in a replication vault.
94+
95+
Args:
96+
cmd: The CLI command context
97+
subscription_id (str): Subscription ID
98+
resource_group_name (str): Resource group name
99+
vault_name (str): Vault name
100+
101+
Returns:
102+
list: List of formatted protected items
103+
104+
Raises:
105+
CLIError: If protected items cannot be listed
106+
"""
107+
from azext_migrate.helpers._utils import (
108+
send_get_request,
109+
APIVersion
110+
)
111+
112+
if not vault_name:
113+
raise CLIError(
114+
"Unable to determine vault name. Please check your project "
115+
"configuration.")
116+
117+
protected_items_uri = (
118+
f"/subscriptions/{subscription_id}/"
119+
f"resourceGroups/{resource_group_name}/"
120+
f"providers/Microsoft.DataReplication/"
121+
f"replicationVaults/{vault_name}/"
122+
f"protectedItems?api-version={APIVersion.Microsoft_DataReplication.value}"
123+
)
124+
125+
request_uri = (
126+
f"{cmd.cli_ctx.cloud.endpoints.resource_manager}{protected_items_uri}")
127+
128+
logger.info(
129+
"Listing protected items from vault '%s'", vault_name)
130+
131+
try:
132+
response = send_get_request(cmd, request_uri)
133+
134+
if not response:
135+
logger.warning("Empty response received when listing protected items")
136+
return []
137+
138+
response_data = response.json() if hasattr(response, 'json') else {}
139+
140+
if not response_data:
141+
logger.warning("No data in response when listing protected items")
142+
return []
143+
144+
protected_items = response_data.get('value', [])
145+
146+
if not protected_items:
147+
logger.info("No protected items found in vault '%s'", vault_name)
148+
print(f"No replicating servers found in project.")
149+
return []
150+
151+
# Handle pagination if nextLink is present
152+
while response_data and response_data.get('nextLink'):
153+
next_link = response_data['nextLink']
154+
response = send_get_request(cmd, next_link)
155+
response_data = response.json() if (
156+
response and hasattr(response, 'json')) else {}
157+
if response_data and response_data.get('value'):
158+
protected_items.extend(response_data['value'])
159+
160+
logger.info(
161+
"Retrieved %d protected items from vault '%s'",
162+
len(protected_items), vault_name)
163+
164+
# Format the protected items for output
165+
formatted_items = []
166+
for item in protected_items:
167+
try:
168+
formatted_item = _format_protected_item(item)
169+
formatted_items.append(formatted_item)
170+
except Exception as format_error:
171+
logger.warning("Error formatting protected item: %s", str(format_error))
172+
# Skip items that fail to format
173+
continue
174+
175+
# Print summary
176+
_print_protected_items_summary(formatted_items)
177+
178+
except Exception as e:
179+
logger.error("Error listing protected items: %s", str(e))
180+
raise CLIError(f"Failed to list protected items: {str(e)}")
181+
182+
183+
def _format_protected_item(item):
184+
"""
185+
Format a protected item for display.
186+
187+
Args:
188+
item (dict): Raw protected item from API
189+
190+
Returns:
191+
dict: Formatted protected item
192+
"""
193+
properties = item.get('properties', {})
194+
custom_properties = properties.get('customProperties', {})
195+
196+
# Extract common properties
197+
formatted_item = {
198+
'id': item.get('id', 'N/A'),
199+
'name': item.get('name', 'N/A'),
200+
'type': item.get('type', 'N/A'),
201+
'protectionState': properties.get('protectionState', 'Unknown'),
202+
'protectionStateDescription': properties.get('protectionStateDescription', 'N/A'),
203+
'replicationHealth': properties.get('replicationHealth', 'Unknown'),
204+
'healthErrors': properties.get('healthErrors', []),
205+
'allowedJobs': properties.get('allowedJobs', []),
206+
'correlationId': properties.get('correlationId', 'N/A'),
207+
'policyName': properties.get('policyName', 'N/A'),
208+
'replicationExtensionName': properties.get('replicationExtensionName', 'N/A'),
209+
}
210+
211+
# Add custom properties if available
212+
if custom_properties:
213+
formatted_item['instanceType'] = custom_properties.get('instanceType', 'N/A')
214+
formatted_item['sourceMachineName'] = custom_properties.get('sourceMachineName', 'N/A')
215+
formatted_item['targetVmName'] = custom_properties.get('targetVmName', 'N/A')
216+
formatted_item['targetResourceGroupId'] = custom_properties.get('targetResourceGroupId', 'N/A')
217+
formatted_item['customLocationRegion'] = custom_properties.get('customLocationRegion', 'N/A')
218+
219+
return formatted_item
220+
221+
222+
def _print_protected_items_summary(items):
223+
"""
224+
Print a summary of protected items.
225+
226+
Args:
227+
items (list): List of formatted protected items
228+
"""
229+
if not items:
230+
return
231+
232+
print(f"\nFound {len(items)} replicating server(s):\n")
233+
print("-" * 120)
234+
235+
for idx, item in enumerate(items, 1):
236+
print(f"\n{idx}. {item.get('name', 'Unknown')}")
237+
print(f" Protection State: {item.get('protectionState', 'Unknown')}")
238+
print(f" Replication Health: {item.get('replicationHealth', 'Unknown')}")
239+
print(f" Source Machine: {item.get('sourceMachineName', 'N/A')}")
240+
print(f" Target VM Name: {item.get('targetVmName', 'N/A')}")
241+
print(f" Policy: {item.get('policyName', 'N/A')}")
242+
print(f" Resource ID: {item.get('id', 'N/A')}")
243+
244+
# Show health errors if any
245+
health_errors = item.get('healthErrors', [])
246+
if health_errors:
247+
print(f" Health Errors: {len(health_errors)} error(s)")
248+
for error in health_errors[:3]: # Show first 3 errors
249+
error_message = error.get('message', 'Unknown error')
250+
print(f" - {error_message}")
251+
252+
print("\n" + "-" * 120)

0 commit comments

Comments
 (0)