From 259f59c1e0e210eac1483d8dfcff43321b1e3606 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 11 Aug 2026 23:34:18 +0530 Subject: [PATCH 01/38] first iteration --- servers/Azure.Mcp.Server/README.md | 2 + servers/Azure.Mcp.Server/TROUBLESHOOTING.md | 18 + .../adishijha-recovery-plan-create.yaml | 3 + .../adishijha-recovery-plan-delete.yaml | 3 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 17 + .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 5 + servers/Azure.Mcp.Server/docs/new-command.md | 11 +- .../src/Resources/consolidated-tools.json | 70 ++-- .../Plans/RecoveryPlanCreateCommand.cs | 123 +++++++ .../Plans/RecoveryPlanDeleteCommand.cs | 81 +++++ .../ResilienceManagementJsonContext.cs | 2 + .../src/Models/RecoveryPlanKind.cs | 10 + .../Plans/RecoveryPlanCreateOption.cs | 35 ++ .../Plans/RecoveryPlanDeleteOption.cs | 22 ++ .../src/ResilienceManagementSetup.cs | 4 + .../Services/IResilienceManagementService.cs | 4 + .../Services/ResilienceManagementService.cs | 125 +++++++ .../Plans/RecoveryPlanCreateCommandTests.cs | 321 ++++++++++++++++++ .../Plans/RecoveryPlanDeleteCommandTests.cs | 114 +++++++ .../ResilienceManagementCommandTests.cs | 119 +++++++ .../ResilienceManagementServiceTests.cs | 137 ++++++++ 21 files changed, 1188 insertions(+), 38 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml create mode 100644 servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanDeleteOption.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 5f692da5a2..271f4137d2 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1237,6 +1237,8 @@ Example prompts that generate Azure CLI commands: * "List the enrollments of usage plan 'my-plan' in resource group 'my-rg'" * "List all resilience recovery plans in service group 'my-service-group'" * "Get the recovery plan 'my-recovery-plan' in service group 'my-service-group'" +* "Create a Zonal recovery plan 'my-recovery-plan' in service group 'my-service-group'" +* "Delete recovery plan 'my-recovery-plan' from service group 'my-service-group'" * "List the recovery jobs of recovery plan 'my-recovery-plan' in service group 'my-service-group'" * "Create a Basic resilience usage plan 'my-plan' in resource group 'my-rg'" * "Enroll service group 'my-service-group' into usage plan 'my-plan' in resource group 'my-rg'" diff --git a/servers/Azure.Mcp.Server/TROUBLESHOOTING.md b/servers/Azure.Mcp.Server/TROUBLESHOOTING.md index 452bff1224..9ed73c72ca 100644 --- a/servers/Azure.Mcp.Server/TROUBLESHOOTING.md +++ b/servers/Azure.Mcp.Server/TROUBLESHOOTING.md @@ -16,6 +16,7 @@ This guide helps you diagnose and resolve common issues with the Azure MCP Serve - [128-Tool Limit Issue](#128-tool-limit-issue) - [How to Check Your Tool Count](#how-to-check-your-tool-count) - [VS Code only shows a subset of tools available](#vs-code-only-shows-a-subset-of-tools-available) + - [A new command is missing from consolidated mode or the README](#a-new-command-is-missing-from-consolidated-mode-or-the-readme) - [VS Code Permission Dialog for Language Model Calls](#vs-code-permission-dialog-for-language-model-calls) - [VS Code Cache Problems](#vs-code-cache-problems) - [MCP Tools That Require Additional Input Fail Silently](#mcp-tools-that-require-additional-input-fail-silently) @@ -239,6 +240,23 @@ The Azure MCP Server can run in multiple modes. Review your MCP configuration to - `azmcp server start --mode single` - Launches an MCP server with a single `azure` tool that performs internal dynamic proxy and tool selection - `azmcp server start --mode namespace` - Explicitly use namespace proxy mode (same as default) +### A new command is missing from consolidated mode or the README + +When a newly registered command appears in `all` mode but is missing from consolidated mode or the public command examples, update all of these surfaces: + +1. Add the command to the best matching `mappedToolList` in `servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json`. The command and consolidated tool must have identical tool metadata. Update the consolidated tool description to mention the new capability. +2. Add a representative prompt to `servers/Azure.Mcp.Server/README.md`. Update the supported-service description when the command introduces a new resource or capability. +3. Update `servers/Azure.Mcp.Server/docs/azmcp-commands.md` and `servers/Azure.Mcp.Server/docs/e2eTestPrompts.md`. +4. Build the server, verify the command registration, and run the consolidated discovery tests: + + ```powershell + dotnet build servers/Azure.Mcp.Server/src/Azure.Mcp.Server.csproj + servers/Azure.Mcp.Server/src/bin/Debug/net10.0/azmcp.exe tools list --namespace --mode all + dotnet test core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Azure.Mcp.Core.Tests.csproj -- --filter-class '*ConsolidatedToolDiscoveryStrategyTests' + ``` + +See the [new command guide](docs/new-command.md#consolidated-mode-requirements) for the complete authoring checklist. + ### VS Code Permission Dialog for Language Model Calls When using the Azure MCP Server in VS Code, you may see a permission dialog requesting authorization for the MCP server to make language model calls: diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml new file mode 100644 index 0000000000..5f192c766d --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added the 'azmcp resilience recovery plan create' command to create a Zonal recovery plan with a system-assigned identity by default or an optional user-assigned identity, or fully update a plan while preserving its existing identity and recovery groups." diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml new file mode 100644 index 0000000000..1d1de73fac --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added the 'azmcp resilience recovery plan delete' command to safely repeat deletion of a recovery plan from an Azure service group." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index ea4473515f..cef4f7f45d 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3711,6 +3711,23 @@ azmcp resilience recovery plan get --subscription \ --service-group \ [--name ] +# Create or fully update a Zonal resilience recovery plan. New plans use a system-assigned identity by default; optionally provide a pre-provisioned user-assigned identity. Updates preserve the existing identity and recovery groups. +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp resilience recovery plan create --service-group \ + --recovery-plan \ + --plan-type Zonal \ + --plan-description \ + [--user-assigned-identity ] \ + [--default-group-description ] + +# After creating a plan with the default system-assigned identity, verify that the new principal has the Azure RBAC roles required by every recovery resource before running recovery operations. For a user-assigned identity, grant those roles before use. Recovery Orchestration attempts role assignment as best effort, so successful plan creation does not guarantee that authorization is complete. +# On update, omit --user-assigned-identity to preserve the current identity, or repeat the same user-assigned identity resource ID. This complete update cannot change identity. + +# Delete a resilience recovery plan. Returns deleted=false when the plan does not exist. +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp resilience recovery plan delete --service-group \ + --recovery-plan + # Get a resource (member) of a recovery plan, or list all resources of the plan (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan resource get --subscription \ diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 7fd76b7856..d21f4d7674 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -900,6 +900,11 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | resilience_recovery_job_get | Get the details of recovery job for recovery plan in service group | | resilience_recovery_job_resource_get | List all resources (targets) of recovery job for recovery plan in service group | | resilience_recovery_job_resource_get | Get the recovery job resource for recovery job of recovery plan in service group | +| resilience_recovery_plan_create | Create a Zonal resilience recovery plan in service group with the default system-assigned identity, description , and default recovery group described as ; remind me to verify the identity has the RBAC roles required by the recovery resources | +| resilience_recovery_plan_create | Create a Zonal resilience recovery plan in service group with pre-provisioned user-assigned identity , description , and default recovery group described as ; remind me to grant the identity the RBAC roles required by the recovery resources | +| resilience_recovery_plan_create | Fully update resilience recovery plan in service group , preserving its managed identity, Zonal plan type, and recovery groups, with description | +| resilience_recovery_plan_delete | Delete resilience recovery plan from service group | +| resilience_recovery_plan_delete | Remove recovery plan from resilience service group ; report whether it existed | | resilience_recovery_plan_get | List all resilience recovery plans in service group | | resilience_recovery_plan_get | Get the details of recovery plan in service group | | resilience_recovery_plan_resource_get | List all resources (members) of recovery plan in service group | diff --git a/servers/Azure.Mcp.Server/docs/new-command.md b/servers/Azure.Mcp.Server/docs/new-command.md index a6fde69bf9..f76013d6f1 100644 --- a/servers/Azure.Mcp.Server/docs/new-command.md +++ b/servers/Azure.Mcp.Server/docs/new-command.md @@ -2747,9 +2747,10 @@ Lists storage accounts in a subscription. ## Consolidated Mode Requirements -Every new command needs to be added to the consolidated mode. Here is the instructions on how to do it: -- `core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json` file is where the tool grouping definition is stored for consolidated mode. +Every new command needs to be added to the consolidated mode. Here are the instructions on how to do it: +- `servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json` is where the tool grouping definition is stored for consolidated mode. - Add the new commands to the one with the best matching category and exact matching toolMetadata. Update existing consolidated tool descriptions where newly mapped tools are added. If you can't find one, suggest a new consolidated tool. +- Update `servers/Azure.Mcp.Server/README.md` with at least one representative prompt for the new command and update the supported-service description when the command adds a new resource or capability. - Use the following command to find out the correct tool name for your new tool ``` cd servers/Azure.Mcp.Server/src/bin/Debug/net10.0 @@ -2771,8 +2772,10 @@ Before submitting: - [ ] Command registered in toolset setup RegisterCommands method - [ ] Follows file structure exactly - [ ] Error handling implemented -- [ ] New tools have been added to consolidated-tools.json -- [ ] Documentation complete +- [ ] New tools have been mapped in `servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json`, and the consolidated tool description reflects the new capability +- [ ] `servers/Azure.Mcp.Server/README.md` includes a representative prompt and an updated supported-service description when applicable +- [ ] `servers/Azure.Mcp.Server/docs/azmcp-commands.md` documents the command +- [ ] `servers/Azure.Mcp.Server/docs/e2eTestPrompts.md` includes command prompts ### **CRITICAL: Live Test Infrastructure (Required for Azure Service Commands)** diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 377818306d..7a47dec28a 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -43,7 +43,7 @@ }, { "name": "create_azure_resilience_management_resources", - "description": "Create or update Azure Resilience Management resources, including usage plans and usage plan enrollments, for Azure service groups.", + "description": "Create, update, or delete Azure Resilience Management resources, including usage plans, usage plan enrollments, and recovery plans for Azure service groups.", "toolMetadata": { "destructive": { "value": true, @@ -72,7 +72,9 @@ }, "mappedToolList": [ "resilience_usageplan_create", - "resilience_usageplan_enrollment_create" + "resilience_usageplan_enrollment_create", + "resilience_recovery_plan_create", + "resilience_recovery_plan_delete" ] }, { @@ -885,37 +887,37 @@ ] }, { - "name": "apply_azure_advisor_recommendations", - "description": "Get rules that can help apply Advisor recommendation to create or modify IaaC files (like ARM, Bicep) for Azure resources.", - "toolMetadata": { - "destructive": { - "value": false, - "description": "This tool performs only additive updates without deleting or modifying existing resources." - }, - "idempotent": { - "value": true, - "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." - }, - "openWorld": { - "value": false, - "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." - }, - "readOnly": { - "value": true, - "description": "This tool only performs read operations without modifying any state or data." - }, - "secret": { - "value": false, - "description": "This tool does not handle sensitive or secret information." - }, - "localRequired": { - "value": false, - "description": "This tool is available in both local and remote server modes." - } - }, - "mappedToolList": [ - "advisor_recommendation_apply" - ] + "name": "apply_azure_advisor_recommendations", + "description": "Get rules that can help apply Advisor recommendation to create or modify IaaC files (like ARM, Bicep) for Azure resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "advisor_recommendation_apply" + ] }, { "name": "get_azure_retail_pricing", @@ -4509,4 +4511,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs new file mode 100644 index 0000000000..7ac298e54a --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; + +[CommandMetadata( + Id = "2fbfa9e6-0a5e-45e4-923d-0ef3706ef733", + Name = "create", + Title = "Create or Update Resilience Recovery Plan", + Description = """ + Create or fully update a Zonal resilience recovery plan in my service group. New plans use a system-assigned + managed identity by default, or an optional pre-provisioned user-assigned identity. Ensure the selected + identity has the Azure RBAC roles required by the recovery resources; Recovery Orchestration role assignment + is best effort. Updates preserve the existing managed identity, default recovery group ID, additional + recovery groups, and omitted default group description. + """, + Destructive = true, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class RecoveryPlanCreateCommand(ILogger logger, IResilienceManagementService resilienceManagementService) + : AuthenticatedCommand +{ + private readonly ILogger _logger = logger; + private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService; + + public override void ValidateOptions(RecoveryPlanCreateOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (options.PlanType != Models.RecoveryPlanKind.Zonal) + { + validationResult.Errors.Add("Only Zonal recovery plans are currently supported."); + } + + if (options.RecoveryPlan.Length is < 5 or > 24 || !options.RecoveryPlan.All(IsValidRecoveryPlanNameCharacter)) + { + validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); + } + + if (options.PlanDescription.Length > 50) + { + validationResult.Errors.Add("The recovery plan description must not exceed 50 characters."); + } + + if (!string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) + { + try + { + _ = ResilienceManagementService.ParseUserAssignedIdentityResourceId(options.UserAssignedIdentity); + } + catch (ArgumentException ex) + { + validationResult.Errors.Add(ex.Message); + } + } + } + + public override async Task ExecuteAsync(CommandContext context, RecoveryPlanCreateOptions options, CancellationToken cancellationToken) + { + try + { + var recoveryPlan = await _resilienceManagementService.CreateRecoveryPlanAsync( + options.ServiceGroup, + options.RecoveryPlan, + options.PlanType, + options.PlanDescription, + options.UserAssignedIdentity, + options.DefaultGroupDescription, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new RecoveryPlanCreateCommandResult(recoveryPlan), + ResilienceManagementJsonContext.Default.RecoveryPlanCreateCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error creating or updating recovery plan. ServiceGroup: {ServiceGroup}, RecoveryPlan: {RecoveryPlan}, PlanType: {PlanType}.", + options.ServiceGroup, options.RecoveryPlan, options.PlanType); + HandleException(context, ex); + } + + return context.Response; + } + + private static bool IsValidRecoveryPlanNameCharacter(char character) => + character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'; + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + ArgumentException => HttpStatusCode.BadRequest, + _ => base.GetStatusCode(ex) + }; + + protected override string GetErrorMessage(Exception ex) => ex switch + { + ArgumentException argumentException => argumentException.Message, + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + "The recovery plan could not be created or updated because it conflicts with the current resource state.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed creating or updating the recovery plan. Verify you have the required permissions.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Service group not found. Verify the service group exists and you have access.", + RequestFailedException => + "The recovery plan request failed. Verify the request parameters and try again.", + _ => base.GetErrorMessage(ex) + }; + + public record RecoveryPlanCreateCommandResult(System.Text.Json.JsonElement RecoveryPlan); +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs new file mode 100644 index 0000000000..1c2dc801ee --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; + +[CommandMetadata( + Id = "e694c9e4-f134-48b2-b6ed-5f6a617d7d8d", + Name = "delete", + Title = "Delete Resilience Recovery Plan", + Description = "Deletes a resilience recovery plan from an Azure service group. This idempotent operation returns Deleted = false when the recovery plan does not exist.", + Destructive = true, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class RecoveryPlanDeleteCommand(ILogger logger, IResilienceManagementService resilienceManagementService) + : AuthenticatedCommand +{ + private readonly ILogger _logger = logger; + private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService; + + public override void ValidateOptions(RecoveryPlanDeleteOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (options.RecoveryPlan.Length is < 5 or > 24 || !options.RecoveryPlan.All(IsValidRecoveryPlanNameCharacter)) + { + validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); + } + } + + public override async Task ExecuteAsync(CommandContext context, RecoveryPlanDeleteOptions options, CancellationToken cancellationToken) + { + try + { + bool deleted = await _resilienceManagementService.DeleteRecoveryPlanAsync( + options.ServiceGroup, + options.RecoveryPlan, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new RecoveryPlanDeleteCommandResult(deleted, options.RecoveryPlan), + ResilienceManagementJsonContext.Default.RecoveryPlanDeleteCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error deleting recovery plan. ServiceGroup: {ServiceGroup}, RecoveryPlan: {RecoveryPlan}.", + options.ServiceGroup, options.RecoveryPlan); + HandleException(context, ex); + } + + return context.Response; + } + + private static bool IsValidRecoveryPlanNameCharacter(char character) => + character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'; + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + "The recovery plan cannot be deleted in its current state. Complete or cancel active recovery operations and try again.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed deleting the recovery plan. Verify you have permission to delete recovery plans in the service group.", + RequestFailedException => + "The recovery plan delete request failed. Verify the recovery plan, service group, and request parameters, then try again.", + _ => base.GetErrorMessage(ex) + }; + + public sealed record RecoveryPlanDeleteCommandResult(bool Deleted, string RecoveryPlan); +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs index ba7960e093..5fb3033012 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs @@ -41,6 +41,8 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands; [JsonSerializable(typeof(UsagePlanEnrollmentInfoErrorDetails))] [JsonSerializable(typeof(UsagePlanEnrollmentInfoSystemData))] [JsonSerializable(typeof(RecoveryPlanGetCommand.RecoveryPlanGetCommandResult))] +[JsonSerializable(typeof(RecoveryPlanCreateCommand.RecoveryPlanCreateCommandResult))] +[JsonSerializable(typeof(RecoveryPlanDeleteCommand.RecoveryPlanDeleteCommandResult))] [JsonSerializable(typeof(RecoveryResourceGetCommand.RecoveryResourceGetCommandResult))] [JsonSerializable(typeof(RecoveryJobGetCommand.RecoveryJobGetCommandResult))] [JsonSerializable(typeof(RecoveryJobResourceGetCommand.RecoveryJobResourceGetCommandResult))] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs new file mode 100644 index 0000000000..0e4b93c061 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public enum RecoveryPlanKind +{ + Regional, + Zonal +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs new file mode 100644 index 0000000000..1516d242de --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Azure.Mcp.Tools.ResilienceManagement.Models; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; + +public class RecoveryPlanCreateOptions +{ + [Option(Description = "The name of the Azure service group that owns the recovery plan.")] + public required string ServiceGroup { get; set; } + + [Option(Description = "The name of the recovery plan to create or fully update.")] + public required string RecoveryPlan { get; set; } + + [Option(Description = "The recovery plan type. Supported value: Zonal. The type cannot be changed after creation.")] + public required RecoveryPlanKind PlanType { get; set; } + + [Option(Description = "The recovery plan description, up to 50 characters.")] + public required string PlanDescription { get; set; } + + [Option(Description = "The full resource ID of a pre-provisioned user-assigned managed identity. Omit when creating a plan to use a system-assigned identity. On update, omit to preserve the existing identity or specify the same user-assigned identity. Ensure the identity has the Azure RBAC roles required by the recovery resources because Recovery Orchestration role assignment is best effort.")] + public string? UserAssignedIdentity { get; set; } + + [Option(Description = "The default recovery group description. On update, the existing description is preserved when omitted.")] + public string? DefaultGroupDescription { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } + + [OptionContainer(Prefix = "retry")] + public RetryPolicyOptions? RetryPolicy { get; set; } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanDeleteOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanDeleteOption.cs new file mode 100644 index 0000000000..dec0af1093 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanDeleteOption.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; + +public sealed class RecoveryPlanDeleteOptions +{ + [Option(Description = ResilienceManagementOptionDescriptions.ServiceGroup)] + public required string ServiceGroup { get; set; } + + [Option(Description = "The name of the recovery plan to delete.")] + public required string RecoveryPlan { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } + + [OptionContainer(Prefix = "retry")] + public RetryPolicyOptions? RetryPolicy { get; set; } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs index 6a74fed791..2dae2ae59d 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs @@ -35,6 +35,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -94,6 +96,8 @@ and high availability and disaster recovery requirements. recovery.AddSubGroup(recoveryPlans); recoveryPlans.AddCommand(serviceProvider); + recoveryPlans.AddCommand(serviceProvider); + recoveryPlans.AddCommand(serviceProvider); // Create resource subgroup under recovery plan var recoveryResources = new CommandGroup("resource", "Resilience recovery resource operations - Commands for listing and getting the resources (members) of a resilience recovery plan."); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index b0919adc9e..14a39f59c9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -35,6 +35,10 @@ public interface IResilienceManagementService Task GetRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string planDescription, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + + Task DeleteRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task> ListRecoveryResourcesAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetRecoveryResourceAsync(string serviceGroup, string recoveryPlan, string recoveryResource, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 205703677b..f05a2ae119 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -6,6 +6,7 @@ using Azure.Mcp.Core.Services.Azure; using Azure.Mcp.Tools.ResilienceManagement.Models; using Azure.ResourceManager; +using Azure.ResourceManager.Models; using Azure.ResourceManager.ResilienceManagement; using Azure.ResourceManager.ResilienceManagement.Models; using Microsoft.Mcp.Core.Options; @@ -394,6 +395,130 @@ public async Task GetRecoveryPlanAsync(string serviceGroup, string return document.RootElement.Clone(); } + public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string planDescription, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + { + ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); + + var serviceGroupId = new ResourceIdentifier($"/providers/Microsoft.Management/serviceGroups/{serviceGroup}"); + RecoveryPlanCollection recoveryPlans = armClient.GetRecoveryPlans(serviceGroupId); + NullableResponse existingPlan = await recoveryPlans.GetIfExistsAsync(recoveryPlan, cancellationToken); + RecoveryGroupsSetting? existingRecoveryGroups = existingPlan.HasValue + ? existingPlan.Value?.Data?.Properties?.RecoveryGroupsSetting + : null; + RecoveryGroupsSetting recoveryGroups = CreateRecoveryGroupsSetting(existingRecoveryGroups, defaultGroupDescription); + ManagedServiceIdentity identity = CreateRecoveryPlanIdentity(existingPlan.Value?.Data?.Identity, userAssignedIdentity); + var data = new RecoveryPlanData + { + Identity = identity, + Properties = new RecoveryPlanProperties( + new RecoveryPlanType(planType.ToString()), + planDescription, + recoveryGroups) + }; + + ArmOperation operation = await recoveryPlans.CreateOrUpdateAsync( + WaitUntil.Completed, + recoveryPlan, + data, + cancellationToken); + + using JsonDocument document = JsonDocument.Parse(operation.GetRawResponse().Content.ToMemory()); + return document.RootElement.Clone(); + } + + public async Task DeleteRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + { + ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); + + var serviceGroupId = new ResourceIdentifier($"/providers/Microsoft.Management/serviceGroups/{serviceGroup}"); + RecoveryPlanCollection recoveryPlans = armClient.GetRecoveryPlans(serviceGroupId); + NullableResponse existingPlan = await recoveryPlans.GetIfExistsAsync(recoveryPlan, cancellationToken); + if (!existingPlan.HasValue || existingPlan.Value is null) + { + return false; + } + + try + { + await existingPlan.Value.DeleteAsync(WaitUntil.Completed, cancellationToken); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroupsSetting? existingRecoveryGroups, string? defaultGroupDescription) + { + RecoveryGroup? existingDefaultGroup = existingRecoveryGroups?.DefaultGroup; + string defaultGroupId = existingDefaultGroup?.Properties?.GroupUniqueId ?? Guid.NewGuid().ToString(); + defaultGroupDescription ??= existingDefaultGroup?.Properties?.Description ?? "Default recovery group"; + var defaultGroup = new RecoveryGroup + { + Properties = new RecoveryGroupProperties(defaultGroupId, 0, defaultGroupDescription) + }; + var recoveryGroups = new RecoveryGroupsSetting(defaultGroup); + foreach (RecoveryGroup additionalGroup in existingRecoveryGroups?.AdditionalGroups ?? []) + { + recoveryGroups.AdditionalGroups.Add(additionalGroup); + } + + return recoveryGroups; + } + + internal static ManagedServiceIdentity CreateRecoveryPlanIdentity(ManagedServiceIdentity? existingIdentity, string? userAssignedIdentity) + { + if (existingIdentity is not null) + { + if (!string.IsNullOrWhiteSpace(userAssignedIdentity)) + { + ResourceIdentifier suppliedIdentityResourceId = ParseUserAssignedIdentityResourceId(userAssignedIdentity); + if (!existingIdentity.UserAssignedIdentities.ContainsKey(suppliedIdentityResourceId)) + { + throw new ArgumentException( + "The supplied user-assigned identity does not match the recovery plan's existing identity. Identity cannot be changed during a complete update; omit --user-assigned-identity because the existing identity is preserved.", + nameof(userAssignedIdentity)); + } + } + + return existingIdentity; + } + + if (string.IsNullOrWhiteSpace(userAssignedIdentity)) + { + return new ManagedServiceIdentity(ManagedServiceIdentityType.SystemAssigned); + } + + ResourceIdentifier identityResourceId = ParseUserAssignedIdentityResourceId(userAssignedIdentity); + var identity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); + identity.UserAssignedIdentities.Add(identityResourceId, new UserAssignedIdentity()); + return identity; + } + + internal static ResourceIdentifier ParseUserAssignedIdentityResourceId(string userAssignedIdentity) + { + try + { + var identityResourceId = new ResourceIdentifier(userAssignedIdentity); + if (!string.Equals(identityResourceId.ResourceType.ToString(), "Microsoft.ManagedIdentity/userAssignedIdentities", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(identityResourceId.SubscriptionId) || + string.IsNullOrWhiteSpace(identityResourceId.ResourceGroupName) || + string.IsNullOrWhiteSpace(identityResourceId.Name)) + { + throw new FormatException(); + } + + return identityResourceId; + } + catch (Exception ex) when (ex is ArgumentException or FormatException) + { + throw new ArgumentException( + "The user-assigned identity must be a valid Azure resource ID in the format /subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identity}.", + nameof(userAssignedIdentity)); + } + } + public async Task> ListRecoveryResourcesAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs new file mode 100644 index 0000000000..da5c776de0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Tools.ResilienceManagement.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Models; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Microsoft.Mcp.Core.Options; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Recovery.Plans; + +public sealed class RecoveryPlanCreateCommandTests : CommandUnitTestsBase +{ + private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami"; + private const string ValidArgs = "--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description --user-assigned-identity " + UserAssignedIdentityResourceId + " --default-group-description default"; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + Assert.Equal("create", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData(ValidArgs, true)] + [InlineData("--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description", true)] + [InlineData("--recovery-plan plan1 --plan-type Zonal --plan-description description --default-group-description default", false)] + [InlineData("--service-group sg1 --plan-type Zonal --plan-description description --default-group-description default", false)] + [InlineData("--service-group sg1 --recovery-plan plan1 --plan-description description --default-group-description default", false)] + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Element("plan1")); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (!shouldSucceed) + { + Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase); + } + } + + [Theory] + [InlineData("plan")] + [InlineData("1234567890123456789012345")] + [InlineData("bad_name")] + [InlineData("../plan")] + public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName(string recoveryPlan) + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", recoveryPlan, + "--plan-type", "Zonal", + "--plan-description", "description", + "--default-group-description", "default"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("5 to 24 characters", response.Message); + await Service.DidNotReceive().CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ExecuteAsync_RejectsPlanDescriptionOver50Characters() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", new string('a', 51), + "--default-group-description", "default"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("must not exceed 50 characters", response.Message); + } + + [Fact] + public async Task ExecuteAsync_RejectsRegionalPlanType() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Regional", + "--plan-description", "description", + "--default-group-description", "default"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("Only Zonal recovery plans are currently supported", response.Message); + await Service.DidNotReceive().CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ReturnsRecoveryPlanAndForwardsCompletePutOptions() + { + Service.CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + UserAssignedIdentityResourceId, + "default", + null, + null, + Arg.Any()) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync(ValidArgs); + + var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.RecoveryPlanCreateCommandResult); + Assert.Equal("plan1", result.RecoveryPlan.GetProperty("name").GetString()); + await Service.Received(1).CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + UserAssignedIdentityResourceId, + "default", + null, + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ForwardsNullWhenDefaultGroupDescriptionIsOmitted() + { + Service.CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + UserAssignedIdentityResourceId, + null, + null, + null, + Arg.Any()) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--user-assigned-identity", UserAssignedIdentityResourceId); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + UserAssignedIdentityResourceId, + null, + null, + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesConflictWithoutExposingProviderDetails() + { + ConfigureRequestFailure(HttpStatusCode.Conflict, "Provider-specific conflict details"); + + var response = await ExecuteCommandAsync(ValidArgs); + + Assert.Equal(HttpStatusCode.Conflict, response.Status); + Assert.Contains("conflicts with the current resource state", response.Message); + Assert.DoesNotContain("Provider-specific conflict details", response.Message); + } + + [Fact] + public async Task ExecuteAsync_RejectsMalformedUserAssignedIdentityResourceId() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--user-assigned-identity", "/subscriptions/id/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("Microsoft.ManagedIdentity/userAssignedIdentities", response.Message, StringComparison.OrdinalIgnoreCase); + await Service.DidNotReceive().CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ExecuteAsync_ForwardsNullToCreateSystemAssignedIdentityWhenUserAssignedIdentityIsOmitted() + { + Service.CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + null, + null, + null, + null, + Arg.Any()) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + null, + null, + null, + null, + Arg.Any()); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden, "Authorization failed")] + [InlineData(HttpStatusCode.NotFound, "Service group not found")] + [InlineData(HttpStatusCode.BadRequest, "request failed")] + public async Task ExecuteAsync_SanitizesRequestFailedException(HttpStatusCode status, string expectedMessage) + { + const string providerDetails = "Sensitive provider details: request-id=123; endpoint=https://example.invalid"; + ConfigureRequestFailure(status, providerDetails); + + var response = await ExecuteCommandAsync(ValidArgs); + + Assert.Equal(status, response.Status); + Assert.Contains(expectedMessage, response.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(providerDetails, response.Message); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + var response = await ExecuteCommandAsync(ValidArgs); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.StartsWith("Test error", response.Message); + } + + private static JsonElement Element(string name) + => JsonDocument.Parse($"{{\"id\":\"id1\",\"name\":\"{name}\"}}").RootElement.Clone(); + + private void ConfigureRequestFailure(HttpStatusCode status, string message) + { + Service.CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException((int)status, message)); + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs new file mode 100644 index 0000000000..f45ce51103 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tools.ResilienceManagement.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Microsoft.Mcp.Core.Options; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Recovery.Plans; + +public sealed class RecoveryPlanDeleteCommandTests : CommandUnitTestsBase +{ + private const string ValidArgs = "--service-group sg1 --recovery-plan plan1"; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + Assert.Equal("delete", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData(ValidArgs, true)] + [InlineData("--service-group sg1", false)] + [InlineData("--recovery-plan plan1", false)] + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.DeleteRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + } + + [Theory] + [InlineData("plan")] + [InlineData("1234567890123456789012345")] + [InlineData("bad_name")] + [InlineData("../plan")] + public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName(string recoveryPlan) + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", recoveryPlan); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("5 to 24 characters", response.Message); + await Service.DidNotReceive().DeleteRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ExecuteAsync_ReturnsDeleteResult(bool deleted) + { + Service.DeleteRecoveryPlanAsync( + "sg1", + "plan1", + null, + null, + Arg.Any()) + .Returns(deleted); + + var response = await ExecuteCommandAsync(ValidArgs); + + var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.RecoveryPlanDeleteCommandResult); + Assert.Equal(deleted, result.Deleted); + Assert.Equal("plan1", result.RecoveryPlan); + } + + [Theory] + [InlineData(HttpStatusCode.Conflict, "current state")] + [InlineData(HttpStatusCode.Forbidden, "Authorization failed")] + [InlineData(HttpStatusCode.BadRequest, "request failed")] + public async Task ExecuteAsync_SanitizesRequestFailedException(HttpStatusCode status, string expectedMessage) + { + const string providerDetails = "Sensitive provider details: request-id=123; endpoint=https://example.invalid"; + Service.DeleteRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException((int)status, providerDetails)); + + var response = await ExecuteCommandAsync(ValidArgs); + + Assert.Equal(status, response.Status); + Assert.Contains(expectedMessage, response.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(providerDetails, response.Message); + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index c8abe5d7ba..b7dc7c4e4f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -146,6 +146,125 @@ public async Task Should_get_recovery_plan() Assert.False(string.IsNullOrEmpty(plan.AssertProperty("name").GetString())); } + [Fact] + public async Task Should_update_recovery_plan() + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); + var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); + var existingResult = await CallToolAsync( + "resilience_recovery_plan_get", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "name", recoveryPlan } + }); + var existingPlan = existingResult.AssertProperty("recoveryPlan"); + var existingIdentity = existingPlan.AssertProperty("identity").Clone(); + var existingRecoveryGroups = existingPlan + .AssertProperty("properties") + .AssertProperty("recoveryGroupsSetting"); + var existingDefaultGroupProperties = existingRecoveryGroups + .AssertProperty("defaultGroup") + .AssertProperty("properties"); + string[] existingAdditionalGroups = existingRecoveryGroups + .AssertProperty("additionalGroups") + .EnumerateArray() + .Select(group => group.GetRawText()) + .ToArray(); + var defaultGroupId = existingDefaultGroupProperties.AssertProperty("groupUniqueId").GetString(); + var defaultGroupDescription = existingDefaultGroupProperties.AssertProperty("description").GetString(); + Assert.False(string.IsNullOrEmpty(defaultGroupId)); + Assert.False(string.IsNullOrEmpty(defaultGroupDescription)); + + var result = await CallToolAsync( + "resilience_recovery_plan_create", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan }, + { "plan-type", "Zonal" }, + { "plan-description", "Recovery plan created by Azure MCP tests." } + }); + + var plan = result.AssertProperty("recoveryPlan"); + Assert.Equal(recoveryPlan, plan.AssertProperty("name").GetString()); + Assert.True(JsonElement.DeepEquals(existingIdentity, plan.AssertProperty("identity"))); + var updatedRecoveryGroups = plan + .AssertProperty("properties") + .AssertProperty("recoveryGroupsSetting"); + var updatedDefaultGroupProperties = updatedRecoveryGroups + .AssertProperty("defaultGroup") + .AssertProperty("properties"); + Assert.Equal(defaultGroupId, updatedDefaultGroupProperties.AssertProperty("groupUniqueId").GetString()); + Assert.Equal(defaultGroupDescription, updatedDefaultGroupProperties.AssertProperty("description").GetString()); + Assert.Equal( + existingAdditionalGroups, + updatedRecoveryGroups.AssertProperty("additionalGroups").EnumerateArray().Select(group => group.GetRawText())); + } + + [Fact] + public async Task Should_delete_recovery_plan_idempotently() + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); + var recoveryPlan = RegisterOrRetrieveVariable("deleteRecoveryPlanName", $"mcpdel-{Guid.NewGuid().ToString("N")[..8]}"); + bool recoveryPlanExists = false; + + try + { + await CallToolAsync( + "resilience_recovery_plan_create", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan }, + { "plan-type", "Zonal" }, + { "plan-description", "Temporary recovery plan for delete testing." } + }); + recoveryPlanExists = true; + + var deletedResult = await CallToolAsync( + "resilience_recovery_plan_delete", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan } + }); + recoveryPlanExists = false; + + Assert.True(deletedResult.AssertProperty("deleted").GetBoolean()); + Assert.Equal(recoveryPlan, deletedResult.AssertProperty("recoveryPlan").GetString()); + + var alreadyDeletedResult = await CallToolAsync( + "resilience_recovery_plan_delete", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan } + }); + + Assert.False(alreadyDeletedResult.AssertProperty("deleted").GetBoolean()); + } + finally + { + if (recoveryPlanExists) + { + await CallToolAsync( + "resilience_recovery_plan_delete", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan } + }); + } + } + } + [Fact] public async Task Should_list_recovery_resources() { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs new file mode 100644 index 0000000000..5ba4638078 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Azure.ResourceManager.Models; +using Azure.ResourceManager.ResilienceManagement.Models; +using Xunit; + +namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Services; + +public sealed class ResilienceManagementServiceTests +{ + private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami"; + + [Fact] + public void CreateRecoveryGroupsSetting_ForNewPlan_GeneratesDefaultGroupId() + { + RecoveryGroupsSetting result = ResilienceManagementService.CreateRecoveryGroupsSetting(null, null); + + Assert.True(Guid.TryParse(result.DefaultGroup.Properties?.GroupUniqueId, out _)); + Assert.Equal("Default recovery group", result.DefaultGroup.Properties?.Description); + Assert.Empty(result.AdditionalGroups); + } + + [Fact] + public void CreateRecoveryGroupsSetting_ForUpdate_PreservesExistingGroups() + { + var existingDefaultGroup = CreateGroup("7f35c9f5-bec2-455d-8161-c904b2532e5d", 0, "Existing default group"); + var firstAdditionalGroup = CreateGroup("ddcfddaf-d15d-44fe-8472-0f3ee9f0179d", 1, "First additional group"); + var secondAdditionalGroup = CreateGroup("9db5bb96-68ab-443d-87b5-2a2555bf46e8", 2, "Second additional group"); + var existingGroups = new RecoveryGroupsSetting(existingDefaultGroup); + existingGroups.AdditionalGroups.Add(firstAdditionalGroup); + existingGroups.AdditionalGroups.Add(secondAdditionalGroup); + + RecoveryGroupsSetting result = ResilienceManagementService.CreateRecoveryGroupsSetting(existingGroups, null); + + Assert.Equal(existingDefaultGroup.Properties?.GroupUniqueId, result.DefaultGroup.Properties?.GroupUniqueId); + Assert.Equal(existingDefaultGroup.Properties?.Description, result.DefaultGroup.Properties?.Description); + Assert.Equal([firstAdditionalGroup, secondAdditionalGroup], result.AdditionalGroups); + } + + [Fact] + public void CreateRecoveryGroupsSetting_ForUpdate_OverridesDefaultGroupDescription() + { + var existingGroups = new RecoveryGroupsSetting( + CreateGroup("7f35c9f5-bec2-455d-8161-c904b2532e5d", 0, "Existing default group")); + + RecoveryGroupsSetting result = ResilienceManagementService.CreateRecoveryGroupsSetting(existingGroups, "Updated default group"); + + Assert.Equal("7f35c9f5-bec2-455d-8161-c904b2532e5d", result.DefaultGroup.Properties?.GroupUniqueId); + Assert.Equal("Updated default group", result.DefaultGroup.Properties?.Description); + } + + [Fact] + public void CreateRecoveryPlanIdentity_ForNewPlan_UsesUserAssignedIdentity() + { + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(null, UserAssignedIdentityResourceId); + + Assert.Equal(ManagedServiceIdentityType.UserAssigned, result.ManagedServiceIdentityType); + Assert.Contains(new ResourceIdentifier(UserAssignedIdentityResourceId), result.UserAssignedIdentities.Keys); + } + + [Fact] + public void CreateRecoveryPlanIdentity_ForNewPlanWithoutUserAssignedIdentity_UsesSystemAssignedIdentity() + { + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(null, null); + + Assert.Equal(ManagedServiceIdentityType.SystemAssigned, result.ManagedServiceIdentityType); + Assert.Empty(result.UserAssignedIdentities); + } + + [Theory] + [InlineData("SystemAssigned")] + [InlineData("UserAssigned")] + [InlineData("SystemAssigned, UserAssigned")] + public void CreateRecoveryPlanIdentity_ForUpdate_PreservesExistingIdentity(string identityType) + { + var existingIdentity = new ManagedServiceIdentity(new ManagedServiceIdentityType(identityType)); + if (identityType.Contains("UserAssigned", StringComparison.Ordinal)) + { + existingIdentity.UserAssignedIdentities.Add( + new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami"), + new UserAssignedIdentity()); + } + + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(existingIdentity, null); + + Assert.Same(existingIdentity, result); + Assert.Equal(identityType, result.ManagedServiceIdentityType.ToString()); + Assert.Equal(existingIdentity.UserAssignedIdentities, result.UserAssignedIdentities); + } + + [Fact] + public void CreateRecoveryPlanIdentity_ForUpdateWithIdentityOption_RejectsIdentityChange() + { + var existingIdentity = new ManagedServiceIdentity(ManagedServiceIdentityType.SystemAssigned); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.CreateRecoveryPlanIdentity(existingIdentity, UserAssignedIdentityResourceId)); + + Assert.Contains("cannot be changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("existing identity is preserved", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CreateRecoveryPlanIdentity_ForIdempotentUpdate_AcceptsExistingUserAssignedIdentity() + { + var existingIdentity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); + existingIdentity.UserAssignedIdentities.Add( + new ResourceIdentifier(UserAssignedIdentityResourceId), + new UserAssignedIdentity()); + + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity( + existingIdentity, + UserAssignedIdentityResourceId); + + Assert.Same(existingIdentity, result); + } + + [Theory] + [InlineData("not-a-resource-id")] + [InlineData("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account")] + public void ParseUserAssignedIdentityResourceId_RejectsInvalidResourceId(string resourceId) + { + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ParseUserAssignedIdentityResourceId(resourceId)); + + Assert.Contains("Microsoft.ManagedIdentity/userAssignedIdentities", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + private static RecoveryGroup CreateGroup(string groupId, int sequenceNumber, string description) + => new() + { + Properties = new RecoveryGroupProperties(groupId, sequenceNumber, description) + }; +} \ No newline at end of file From 2ec35263da345a902338dfa3ea1177b23b92082c Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 12 Aug 2026 16:42:17 +0530 Subject: [PATCH 02/38] 2nd iteration --- servers/Azure.Mcp.Server/README.md | 8 +- .../adishijha-recovery-plan-create.yaml | 2 +- .../adishijha-recovery-plan-delete.yaml | 2 +- ...shijha-recovery-plan-update-resources.yaml | 3 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 17 +- .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 9 +- .../src/Resources/consolidated-tools.json | 3 +- .../Plans/RecoveryPlanCreateCommand.cs | 29 +- .../Plans/RecoveryPlanDeleteCommand.cs | 7 +- .../RecoveryPlanUpdateResourcesCommand.cs | 230 ++++++++++++++++ .../ResilienceManagementJsonContext.cs | 1 + .../src/Models/RecoveryPlanIdentityKind.cs | 10 + .../src/Models/RecoveryPlanKind.cs | 2 +- .../Plans/RecoveryPlanCreateOption.cs | 11 +- .../Plans/RecoveryPlanDeleteOption.cs | 2 +- .../RecoveryPlanUpdateResourcesOption.cs | 28 ++ .../src/ResilienceManagementSetup.cs | 2 + .../Services/IResilienceManagementService.cs | 3 + .../Services/ResilienceManagementService.cs | 51 ++-- .../Plans/RecoveryPlanCreateCommandTests.cs | 155 ++++++++++- .../Plans/RecoveryPlanDeleteCommandTests.cs | 19 +- ...RecoveryPlanUpdateResourcesCommandTests.cs | 249 ++++++++++++++++++ .../ResilienceManagementCommandTests.cs | 120 +++++++-- .../ResilienceManagementServiceTests.cs | 58 +--- .../tests/remove-test-resources-pre.ps1 | 36 +++ .../tests/test-resources-post.ps1 | 24 ++ .../tests/test-resources.bicep | 4 + 27 files changed, 960 insertions(+), 125 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-update-resources.yaml create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityKind.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 diff --git a/servers/Azure.Mcp.Server/README.md b/servers/Azure.Mcp.Server/README.md index 271f4137d2..0fb49f4cf3 100644 --- a/servers/Azure.Mcp.Server/README.md +++ b/servers/Azure.Mcp.Server/README.md @@ -1237,8 +1237,12 @@ Example prompts that generate Azure CLI commands: * "List the enrollments of usage plan 'my-plan' in resource group 'my-rg'" * "List all resilience recovery plans in service group 'my-service-group'" * "Get the recovery plan 'my-recovery-plan' in service group 'my-service-group'" -* "Create a Zonal recovery plan 'my-recovery-plan' in service group 'my-service-group'" -* "Delete recovery plan 'my-recovery-plan' from service group 'my-service-group'" +* "Create a Zonal recovery plan 'my-recovery-plan' in service group 'my-service-group' with a system-assigned identity" +* "Update recovery plan 'my-recovery-plan' in service group 'my-service-group' to use user-assigned identity '/subscriptions/my-subscription/resourceGroups/my-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-identity'" +* "Include recovery resource 'my-resource' in recovery plan 'my-recovery-plan' in service group 'my-service-group' and configure its protection settings" +* "Exclude recovery resource 'my-resource' from recovery plan 'my-recovery-plan' in service group 'my-service-group'" +* "Remove recovery resource 'my-resource' from recovery plan 'my-recovery-plan' in service group 'my-service-group'" +* "Delete recovery plan 'my-recovery-plan' from service group 'my-service-group' and report whether it existed" * "List the recovery jobs of recovery plan 'my-recovery-plan' in service group 'my-service-group'" * "Create a Basic resilience usage plan 'my-plan' in resource group 'my-rg'" * "Enroll service group 'my-service-group' into usage plan 'my-plan' in resource group 'my-rg'" diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml index 5f192c766d..5a4f27457f 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml @@ -1,3 +1,3 @@ changes: - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan create' command to create a Zonal recovery plan with a system-assigned identity by default or an optional user-assigned identity, or fully update a plan while preserving its existing identity and recovery groups." + description: "Added the 'azmcp resilience recovery plan create' command to create or fully update a Zonal recovery plan with an explicitly selected system-assigned or user-assigned identity. Updates can switch identity types while preserving existing recovery groups." diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml index 1d1de73fac..24b3dd880f 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml @@ -1,3 +1,3 @@ changes: - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan delete' command to safely repeat deletion of a recovery plan from an Azure service group." + description: "Added the 'azmcp resilience recovery plan delete' command to safely repeat deletion of a recovery plan from an Azure service group and report whether the plan existed." diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-update-resources.yaml b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-update-resources.yaml new file mode 100644 index 0000000000..cd1300d3f6 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-update-resources.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added the 'azmcp resilience recovery plan update-resources' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index cef4f7f45d..3cd8d72ebc 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3711,23 +3711,34 @@ azmcp resilience recovery plan get --subscription \ --service-group \ [--name ] -# Create or fully update a Zonal resilience recovery plan. New plans use a system-assigned identity by default; optionally provide a pre-provisioned user-assigned identity. Updates preserve the existing identity and recovery groups. +# Create or fully update a Zonal resilience recovery plan. An identity is required, and updates can switch between system-assigned and user-assigned identities. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan create --service-group \ --recovery-plan \ --plan-type Zonal \ --plan-description \ + --identity-type \ [--user-assigned-identity ] \ [--default-group-description ] -# After creating a plan with the default system-assigned identity, verify that the new principal has the Azure RBAC roles required by every recovery resource before running recovery operations. For a user-assigned identity, grant those roles before use. Recovery Orchestration attempts role assignment as best effort, so successful plan creation does not guarantee that authorization is complete. -# On update, omit --user-assigned-identity to preserve the current identity, or repeat the same user-assigned identity resource ID. This complete update cannot change identity. +# Provide --user-assigned-identity only when --identity-type is UserAssigned. # Delete a resilience recovery plan. Returns deleted=false when the plan does not exist. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan delete --service-group \ --recovery-plan +# Configure recovery-plan resource inclusions, exclusions, removals, recovery groups, identities, and protection settings. At least one JSON array is required. +# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp resilience recovery plan update-resources --service-group \ + --recovery-plan \ + [--resources-to-update ''] \ + [--resources-to-remove ''] + +# Each update item requires properties.recoveryResourceUniqueId. The read-only id may be omitted; when supplied, it must identify that resource in the selected plan. +# Example update item: +# [{"properties":{"recoveryResourceUniqueId":"","inclusionState":"Included","selectedProtectionSolutionType":"AzureNative","selectedProtectionSolutionSetting":{"protectionSolutionType":"AzureNative"}}}] + # Get a resource (member) of a recovery plan, or list all resources of the plan (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan resource get --subscription \ diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index d21f4d7674..3a281ea3b6 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -900,13 +900,16 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | resilience_recovery_job_get | Get the details of recovery job for recovery plan in service group | | resilience_recovery_job_resource_get | List all resources (targets) of recovery job for recovery plan in service group | | resilience_recovery_job_resource_get | Get the recovery job resource for recovery job of recovery plan in service group | -| resilience_recovery_plan_create | Create a Zonal resilience recovery plan in service group with the default system-assigned identity, description , and default recovery group described as ; remind me to verify the identity has the RBAC roles required by the recovery resources | -| resilience_recovery_plan_create | Create a Zonal resilience recovery plan in service group with pre-provisioned user-assigned identity , description , and default recovery group described as ; remind me to grant the identity the RBAC roles required by the recovery resources | -| resilience_recovery_plan_create | Fully update resilience recovery plan in service group , preserving its managed identity, Zonal plan type, and recovery groups, with description | +| resilience_recovery_plan_create | Create a Zonal resilience recovery plan in service group with a system-assigned identity, description , and default recovery group described as | +| resilience_recovery_plan_create | Create a Zonal resilience recovery plan in service group with user-assigned identity , description , and default recovery group described as | +| resilience_recovery_plan_create | Fully update resilience recovery plan in service group , switching it to a system-assigned identity while preserving its Zonal plan type and recovery groups, with description | | resilience_recovery_plan_delete | Delete resilience recovery plan from service group | | resilience_recovery_plan_delete | Remove recovery plan from resilience service group ; report whether it existed | | resilience_recovery_plan_get | List all resilience recovery plans in service group | | resilience_recovery_plan_get | Get the details of recovery plan in service group | +| resilience_recovery_plan_update-resources | Include recovery resource in recovery plan in service group using protection solution and protection settings | +| resilience_recovery_plan_update-resources | Exclude recovery resource from recovery plan in service group | +| resilience_recovery_plan_update-resources | Remove recovery resource from recovery plan in service group | | resilience_recovery_plan_resource_get | List all resources (members) of recovery plan in service group | | resilience_recovery_plan_resource_get | Get the recovery resource for recovery plan in service group | | resilience_usageplan_create | Create a resilience usage plan with plan type Basic in resource group | diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 7a47dec28a..7ae3eabd9c 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -74,7 +74,8 @@ "resilience_usageplan_create", "resilience_usageplan_enrollment_create", "resilience_recovery_plan_create", - "resilience_recovery_plan_delete" + "resilience_recovery_plan_delete", + "resilience_recovery_plan_update-resources" ] }, { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index 7ac298e54a..35f1849cf2 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -16,11 +16,9 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Name = "create", Title = "Create or Update Resilience Recovery Plan", Description = """ - Create or fully update a Zonal resilience recovery plan in my service group. New plans use a system-assigned - managed identity by default, or an optional pre-provisioned user-assigned identity. Ensure the selected - identity has the Azure RBAC roles required by the recovery resources; Recovery Orchestration role assignment - is best effort. Updates preserve the existing managed identity, default recovery group ID, additional - recovery groups, and omitted default group description. + Create or fully update a Zonal resilience recovery plan in my service group with a required system-assigned + or user-assigned managed identity. Updates can switch between identity types and preserve the default recovery + group ID, additional recovery groups, and omitted default group description. """, Destructive = true, Idempotent = true, @@ -48,12 +46,25 @@ public override void ValidateOptions(RecoveryPlanCreateOptions options, Validati validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); } - if (options.PlanDescription.Length > 50) + if (options.PlanDescription.Length is < 5 or > 50) { - validationResult.Errors.Add("The recovery plan description must not exceed 50 characters."); + validationResult.Errors.Add("The recovery plan description must be 5 to 50 characters."); } - if (!string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) + if (options.DefaultGroupDescription is not null && options.DefaultGroupDescription.Length is < 5 or > 50) + { + validationResult.Errors.Add("The default recovery group description must be 5 to 50 characters when specified."); + } + + if (options.IdentityType == Models.RecoveryPlanIdentityKind.UserAssigned && string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) + { + validationResult.Errors.Add("--user-assigned-identity is required when --identity-type is UserAssigned."); + } + else if (options.IdentityType == Models.RecoveryPlanIdentityKind.SystemAssigned && !string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) + { + validationResult.Errors.Add("--user-assigned-identity is not allowed when --identity-type is SystemAssigned."); + } + else if (!string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) { try { @@ -120,4 +131,4 @@ private static bool IsValidRecoveryPlanNameCharacter(char character) => }; public record RecoveryPlanCreateCommandResult(System.Text.Json.JsonElement RecoveryPlan); -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs index 1c2dc801ee..e9d9bf8538 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Net; +using System.Text.Json.Serialization; using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; using Azure.Mcp.Tools.ResilienceManagement.Services; using Microsoft.Extensions.Logging; @@ -77,5 +78,7 @@ private static bool IsValidRecoveryPlanNameCharacter(char character) => _ => base.GetErrorMessage(ex) }; - public sealed record RecoveryPlanDeleteCommandResult(bool Deleted, string RecoveryPlan); -} \ No newline at end of file + public sealed record RecoveryPlanDeleteCommandResult( + [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] bool Deleted, + string RecoveryPlan); +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs new file mode 100644 index 0000000000..9094f23b2a --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Azure.ResourceManager.ResilienceManagement.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; + +[CommandMetadata( + Id = "ace3cbba-d572-47dc-a452-ab2cb349e17b", + Name = "update-resources", + Title = "Update Resilience Recovery Plan Resources", + Description = "Includes a recovery resource in an Azure Resilience recovery plan and configures its protection solution type and settings. Excludes a resource from a recovery plan. Removes a recovery resource from a recovery plan. Also assigns recovery groups and managed identities.", + Destructive = true, + Idempotent = true, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class RecoveryPlanUpdateResourcesCommand(ILogger logger, IResilienceManagementService resilienceManagementService) + : AuthenticatedCommand +{ + private const int MaxPayloadLength = 1_048_576; + private readonly ILogger _logger = logger; + private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService; + + public override void ValidateOptions(RecoveryPlanUpdateResourcesOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + if (options.RecoveryPlan.Length is < 5 or > 24 || !options.RecoveryPlan.All(IsValidRecoveryPlanNameCharacter)) + { + validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); + } + + try + { + _ = CreateContent(options); + } + catch (ArgumentException ex) + { + validationResult.Errors.Add(ex.Message); + } + } + + public override async Task ExecuteAsync(CommandContext context, RecoveryPlanUpdateResourcesOptions options, CancellationToken cancellationToken) + { + try + { + UpdateRecoveryResourcesContent content = CreateContent(options); + var result = await _resilienceManagementService.UpdateRecoveryPlanResourcesAsync( + options.ServiceGroup, + options.RecoveryPlan, + content, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new RecoveryPlanUpdateResourcesCommandResult(result), + ResilienceManagementJsonContext.Default.RecoveryPlanUpdateResourcesCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error updating recovery plan resources. ServiceGroup: {ServiceGroup}, RecoveryPlan: {RecoveryPlan}.", + options.ServiceGroup, options.RecoveryPlan); + HandleException(context, ex); + } + + return context.Response; + } + + internal static UpdateRecoveryResourcesContent CreateContent(RecoveryPlanUpdateResourcesOptions options) + { + if (string.IsNullOrWhiteSpace(options.ResourcesToUpdate) && string.IsNullOrWhiteSpace(options.ResourcesToRemove)) + { + throw new ArgumentException("Specify at least one of --resources-to-update or --resources-to-remove."); + } + + if (options.ResourcesToUpdate?.Length > MaxPayloadLength || options.ResourcesToRemove?.Length > MaxPayloadLength) + { + throw new ArgumentException("Each recovery resource JSON payload must not exceed 1 MB."); + } + + try + { + using JsonDocument updates = JsonDocument.Parse(options.ResourcesToUpdate ?? "[]"); + using JsonDocument removals = JsonDocument.Parse(options.ResourcesToRemove ?? "[]"); + ValidateResourceArrays(options, updates.RootElement, removals.RootElement); + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WritePropertyName("resourcesToUpdate"); + updates.RootElement.WriteTo(writer); + writer.WritePropertyName("resourcesToRemove"); + removals.RootElement.WriteTo(writer); + writer.WriteEndObject(); + } + + var reader = new Utf8JsonReader(stream.ToArray()); + var model = new UpdateRecoveryResourcesContent(); + return ((IJsonModel)model).Create( + ref reader, + ModelReaderWriterOptions.Json) ?? + throw new ArgumentException("The recovery resource configuration could not be parsed."); + } + catch (JsonException ex) + { + throw new ArgumentException("Recovery resource inputs must be valid JSON arrays.", ex); + } + } + + private static void ValidateResourceArrays(RecoveryPlanUpdateResourcesOptions options, JsonElement updates, JsonElement removals) + { + if (updates.ValueKind != JsonValueKind.Array || removals.ValueKind != JsonValueKind.Array) + { + throw new ArgumentException("Recovery resource inputs must be JSON arrays."); + } + + var updatedIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (JsonElement update in updates.EnumerateArray()) + { + if (update.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("Each resource in --resources-to-update must be an object."); + } + + if (!update.TryGetProperty("properties", out JsonElement properties) || + properties.ValueKind != JsonValueKind.Object || + !properties.TryGetProperty("recoveryResourceUniqueId", out JsonElement uniqueIdElement) || + uniqueIdElement.ValueKind != JsonValueKind.String || + !Guid.TryParse(uniqueIdElement.GetString(), out Guid uniqueId)) + { + throw new ArgumentException("Each resource in --resources-to-update must contain a properties.recoveryResourceUniqueId GUID."); + } + + string id = CreateRecoveryResourceId(options.ServiceGroup, options.RecoveryPlan, uniqueId.ToString()); + if (update.TryGetProperty("id", out JsonElement idElement) && + (idElement.ValueKind != JsonValueKind.String || + !string.Equals(idElement.GetString(), id, StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException("A supplied recovery resource id must match properties.recoveryResourceUniqueId and belong to the selected recovery plan."); + } + + if (!updatedIds.Add(id)) + { + throw new ArgumentException($"Recovery resource '{id}' appears more than once in --resources-to-update."); + } + + if (properties.TryGetProperty("inclusionState", out JsonElement inclusionState) && + inclusionState.ValueKind != JsonValueKind.Null && + (inclusionState.ValueKind != JsonValueKind.String || + inclusionState.GetString() is not ("Included" or "Excluded"))) + { + throw new ArgumentException("A recovery resource inclusionState must be Included, Excluded, or null."); + } + } + + var removedIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (JsonElement removal in removals.EnumerateArray()) + { + if (removal.ValueKind != JsonValueKind.String) + { + throw new ArgumentException("Each value in --resources-to-remove must be a recovery-resource ID string."); + } + + string id = removal.GetString()!; + ValidateRecoveryResourceId(id, options.ServiceGroup, options.RecoveryPlan); + if (!removedIds.Add(id)) + { + throw new ArgumentException($"Recovery resource '{id}' appears more than once in --resources-to-remove."); + } + + if (updatedIds.Contains(id)) + { + throw new ArgumentException($"Recovery resource '{id}' cannot be updated and removed in the same request."); + } + } + } + + private static string CreateRecoveryResourceId(string serviceGroup, string recoveryPlan, string recoveryResourceUniqueId) => + $"/providers/Microsoft.Management/serviceGroups/{serviceGroup}/providers/Microsoft.AzureResilienceManagement/recoveryPlans/{recoveryPlan}/recoveryResources/{recoveryResourceUniqueId}"; + + private static void ValidateRecoveryResourceId(string id, string serviceGroup, string recoveryPlan) + { + string expectedPrefix = CreateRecoveryResourceId(serviceGroup, recoveryPlan, string.Empty); + if (!id.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase) || + id.Length == expectedPrefix.Length || + id.AsSpan(expectedPrefix.Length).Contains('/')) + { + throw new ArgumentException($"Recovery resource IDs must belong to service group '{serviceGroup}' and recovery plan '{recoveryPlan}'."); + } + } + + private static bool IsValidRecoveryPlanNameCharacter(char character) => + character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'; + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + ArgumentException => HttpStatusCode.BadRequest, + _ => base.GetStatusCode(ex) + }; + + protected override string GetErrorMessage(Exception ex) => ex switch + { + ArgumentException argumentException => argumentException.Message, + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + "Recovery plan resources cannot be updated while another recovery operation is in progress.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed updating recovery plan resources. Verify you have the required permissions.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Recovery plan not found. Verify the recovery plan and service group exist and you have access.", + RequestFailedException => + "The recovery plan resource update failed. Verify the resource IDs and protection settings, then try again.", + _ => base.GetErrorMessage(ex) + }; + + public sealed record RecoveryPlanUpdateResourcesCommandResult(JsonElement Result); +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs index 5fb3033012..d02927110a 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs @@ -43,6 +43,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands; [JsonSerializable(typeof(RecoveryPlanGetCommand.RecoveryPlanGetCommandResult))] [JsonSerializable(typeof(RecoveryPlanCreateCommand.RecoveryPlanCreateCommandResult))] [JsonSerializable(typeof(RecoveryPlanDeleteCommand.RecoveryPlanDeleteCommandResult))] +[JsonSerializable(typeof(RecoveryPlanUpdateResourcesCommand.RecoveryPlanUpdateResourcesCommandResult))] [JsonSerializable(typeof(RecoveryResourceGetCommand.RecoveryResourceGetCommandResult))] [JsonSerializable(typeof(RecoveryJobGetCommand.RecoveryJobGetCommandResult))] [JsonSerializable(typeof(RecoveryJobResourceGetCommand.RecoveryJobResourceGetCommandResult))] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityKind.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityKind.cs new file mode 100644 index 0000000000..699e115226 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityKind.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public enum RecoveryPlanIdentityKind +{ + SystemAssigned, + UserAssigned +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs index 0e4b93c061..69f5edfa35 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs @@ -7,4 +7,4 @@ public enum RecoveryPlanKind { Regional, Zonal -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs index 1516d242de..da748ab746 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs @@ -18,13 +18,16 @@ public class RecoveryPlanCreateOptions [Option(Description = "The recovery plan type. Supported value: Zonal. The type cannot be changed after creation.")] public required RecoveryPlanKind PlanType { get; set; } - [Option(Description = "The recovery plan description, up to 50 characters.")] + [Option(Description = "The recovery plan description, from 5 to 50 characters.")] public required string PlanDescription { get; set; } - [Option(Description = "The full resource ID of a pre-provisioned user-assigned managed identity. Omit when creating a plan to use a system-assigned identity. On update, omit to preserve the existing identity or specify the same user-assigned identity. Ensure the identity has the Azure RBAC roles required by the recovery resources because Recovery Orchestration role assignment is best effort.")] + [Option(Description = "The managed identity type for the recovery plan. Supported values: SystemAssigned and UserAssigned. Specify this on every create or update; updates can switch identity types.")] + public required RecoveryPlanIdentityKind IdentityType { get; set; } + + [Option(Description = "The full resource ID of the user-assigned managed identity. Required when --identity-type is UserAssigned and not allowed when it is SystemAssigned.")] public string? UserAssignedIdentity { get; set; } - [Option(Description = "The default recovery group description. On update, the existing description is preserved when omitted.")] + [Option(Description = "The default recovery group description, from 5 to 50 characters. On update, the existing description is preserved when omitted.")] public string? DefaultGroupDescription { get; set; } [Option(Description = OptionDescriptions.Tenant)] @@ -32,4 +35,4 @@ public class RecoveryPlanCreateOptions [OptionContainer(Prefix = "retry")] public RetryPolicyOptions? RetryPolicy { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanDeleteOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanDeleteOption.cs index dec0af1093..7980c3fb47 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanDeleteOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanDeleteOption.cs @@ -19,4 +19,4 @@ public sealed class RecoveryPlanDeleteOptions [OptionContainer(Prefix = "retry")] public RetryPolicyOptions? RetryPolicy { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs new file mode 100644 index 0000000000..ad9c36100b --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; + +public sealed class RecoveryPlanUpdateResourcesOptions +{ + [Option(Description = "The name of the Azure service group that owns the recovery plan.")] + public required string ServiceGroup { get; set; } + + [Option(Description = "The name of the recovery plan whose resources will be updated.")] + public required string RecoveryPlan { get; set; } + + [Option(Description = "A JSON array of recovery resources to include, exclude, or configure. Each item must contain a properties object with recoveryResourceUniqueId. The read-only id may be omitted; when supplied, it must match the unique ID and selected recovery plan. Supported caller-controlled properties include inclusionState, selectedProtectionSolutionType, selectedProtectionSolutionSetting, recoveryGroupId, and associatedIdentity.")] + public string? ResourcesToUpdate { get; set; } + + [Option(Description = "A JSON array of full recovery-resource IDs to remove from the recovery plan.")] + public string? ResourcesToRemove { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } + + [OptionContainer(Prefix = "retry")] + public RetryPolicyOptions? RetryPolicy { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs index 2dae2ae59d..e75489b4f6 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs @@ -37,6 +37,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -98,6 +99,7 @@ and high availability and disaster recovery requirements. recoveryPlans.AddCommand(serviceProvider); recoveryPlans.AddCommand(serviceProvider); recoveryPlans.AddCommand(serviceProvider); + recoveryPlans.AddCommand(serviceProvider); // Create resource subgroup under recovery plan var recoveryResources = new CommandGroup("resource", "Resilience recovery resource operations - Commands for listing and getting the resources (members) of a resilience recovery plan."); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index 14a39f59c9..25d213bc47 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Azure.Mcp.Tools.ResilienceManagement.Models; +using Azure.ResourceManager.ResilienceManagement.Models; using Microsoft.Mcp.Core.Options; namespace Azure.Mcp.Tools.ResilienceManagement.Services; @@ -39,6 +40,8 @@ public interface IResilienceManagementService Task DeleteRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task UpdateRecoveryPlanResourcesAsync(string serviceGroup, string recoveryPlan, UpdateRecoveryResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task> ListRecoveryResourcesAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetRecoveryResourceAsync(string serviceGroup, string recoveryPlan, string recoveryResource, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index f05a2ae119..3e96504471 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.ClientModel.Primitives; using System.Text.Json; using Azure.Core; using Azure.Mcp.Core.Services.Azure; @@ -406,7 +407,7 @@ public async Task CreateRecoveryPlanAsync(string serviceGroup, stri ? existingPlan.Value?.Data?.Properties?.RecoveryGroupsSetting : null; RecoveryGroupsSetting recoveryGroups = CreateRecoveryGroupsSetting(existingRecoveryGroups, defaultGroupDescription); - ManagedServiceIdentity identity = CreateRecoveryPlanIdentity(existingPlan.Value?.Data?.Identity, userAssignedIdentity); + ManagedServiceIdentity identity = CreateRecoveryPlanIdentity(userAssignedIdentity); var data = new RecoveryPlanData { Identity = identity, @@ -449,6 +450,34 @@ public async Task DeleteRecoveryPlanAsync(string serviceGroup, string reco } } + public async Task UpdateRecoveryPlanResourcesAsync(string serviceGroup, string recoveryPlan, UpdateRecoveryResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + { + ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); + + var recoveryPlanId = RecoveryPlanResource.CreateResourceIdentifier(serviceGroup, recoveryPlan); + RecoveryPlanResource recoveryPlanResource = await armClient.GetRecoveryPlanResource(recoveryPlanId).GetAsync(cancellationToken); + ArmOperation operation = await recoveryPlanResource.UpdateResourcesAsync( + WaitUntil.Completed, + Guid.NewGuid().ToString(), + content, + cancellationToken); + + if (operation.Value.FailedResources.Count == 0) + { + using JsonDocument emptyResult = JsonDocument.Parse("""{"failedResources":[]}"""); + return emptyResult.RootElement.Clone(); + } + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + ((IJsonModel)operation.Value).Write(writer, ModelReaderWriterOptions.Json); + } + + using JsonDocument document = JsonDocument.Parse(stream.ToArray()); + return document.RootElement.Clone(); + } + internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroupsSetting? existingRecoveryGroups, string? defaultGroupDescription) { RecoveryGroup? existingDefaultGroup = existingRecoveryGroups?.DefaultGroup; @@ -467,30 +496,14 @@ internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroups return recoveryGroups; } - internal static ManagedServiceIdentity CreateRecoveryPlanIdentity(ManagedServiceIdentity? existingIdentity, string? userAssignedIdentity) + internal static ManagedServiceIdentity CreateRecoveryPlanIdentity(string? userAssignedIdentity) { - if (existingIdentity is not null) - { - if (!string.IsNullOrWhiteSpace(userAssignedIdentity)) - { - ResourceIdentifier suppliedIdentityResourceId = ParseUserAssignedIdentityResourceId(userAssignedIdentity); - if (!existingIdentity.UserAssignedIdentities.ContainsKey(suppliedIdentityResourceId)) - { - throw new ArgumentException( - "The supplied user-assigned identity does not match the recovery plan's existing identity. Identity cannot be changed during a complete update; omit --user-assigned-identity because the existing identity is preserved.", - nameof(userAssignedIdentity)); - } - } - - return existingIdentity; - } - if (string.IsNullOrWhiteSpace(userAssignedIdentity)) { return new ManagedServiceIdentity(ManagedServiceIdentityType.SystemAssigned); } - ResourceIdentifier identityResourceId = ParseUserAssignedIdentityResourceId(userAssignedIdentity); + ResourceIdentifier identityResourceId = ParseUserAssignedIdentityResourceId(userAssignedIdentity!); var identity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); identity.UserAssignedIdentities.Add(identityResourceId, new UserAssignedIdentity()); return identity; diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs index da5c776de0..50b605799f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs @@ -18,7 +18,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Recovery.Plans; public sealed class RecoveryPlanCreateCommandTests : CommandUnitTestsBase { private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami"; - private const string ValidArgs = "--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description --user-assigned-identity " + UserAssignedIdentityResourceId + " --default-group-description default"; + private const string ValidArgs = "--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description --identity-type UserAssigned --user-assigned-identity " + UserAssignedIdentityResourceId + " --default-group-description default"; [Fact] public void Constructor_InitializesCommandCorrectly() @@ -31,7 +31,8 @@ public void Constructor_InitializesCommandCorrectly() [Theory] [InlineData(ValidArgs, true)] - [InlineData("--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description", true)] + [InlineData("--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description --identity-type SystemAssigned", true)] + [InlineData("--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description", false)] [InlineData("--recovery-plan plan1 --plan-type Zonal --plan-description description --default-group-description default", false)] [InlineData("--service-group sg1 --plan-type Zonal --plan-description description --default-group-description default", false)] [InlineData("--service-group sg1 --recovery-plan plan1 --plan-description description --default-group-description default", false)] @@ -74,6 +75,7 @@ public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName(string recoveryPla "--recovery-plan", recoveryPlan, "--plan-type", "Zonal", "--plan-description", "description", + "--identity-type", "SystemAssigned", "--default-group-description", "default"); Assert.Equal(HttpStatusCode.BadRequest, response.Status); @@ -90,18 +92,120 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( TestContext.Current.CancellationToken); } - [Fact] - public async Task ExecuteAsync_RejectsPlanDescriptionOver50Characters() + [Theory] + [InlineData("plan1")] + [InlineData("123456789012345678901234")] + public async Task ExecuteAsync_AcceptsRecoveryPlanNameBoundaryLengths(string recoveryPlan) + { + Service.CreateRecoveryPlanAsync( + Arg.Any(), + recoveryPlan, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Element(recoveryPlan)); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", recoveryPlan, + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Theory] + [InlineData("four")] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + public async Task ExecuteAsync_RejectsPlanDescriptionOutsideAllowedLength(string planDescription) { var response = await ExecuteCommandAsync( "--service-group", "sg1", "--recovery-plan", "plan1", "--plan-type", "Zonal", - "--plan-description", new string('a', 51), + "--plan-description", planDescription, + "--identity-type", "SystemAssigned", "--default-group-description", "default"); Assert.Equal(HttpStatusCode.BadRequest, response.Status); - Assert.Contains("must not exceed 50 characters", response.Message); + Assert.Contains("5 to 50 characters", response.Message); + } + + [Theory] + [InlineData("12345")] + [InlineData("12345678901234567890123456789012345678901234567890")] + public async Task ExecuteAsync_AcceptsPlanDescriptionBoundaryLengths(string planDescription) + { + Service.CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + planDescription, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", planDescription, + "--identity-type", "SystemAssigned"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Theory] + [InlineData("four")] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + public async Task ExecuteAsync_RejectsDefaultGroupDescriptionOutsideAllowedLength(string defaultGroupDescription) + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--default-group-description", defaultGroupDescription); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("default recovery group description must be 5 to 50 characters", response.Message); + } + + [Theory] + [InlineData("12345")] + [InlineData("12345678901234567890123456789012345678901234567890")] + public async Task ExecuteAsync_AcceptsDefaultGroupDescriptionBoundaryLengths(string defaultGroupDescription) + { + Service.CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + defaultGroupDescription, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--default-group-description", defaultGroupDescription); + + Assert.Equal(HttpStatusCode.OK, response.Status); } [Fact] @@ -112,6 +216,7 @@ public async Task ExecuteAsync_RejectsRegionalPlanType() "--recovery-plan", "plan1", "--plan-type", "Regional", "--plan-description", "description", + "--identity-type", "SystemAssigned", "--default-group-description", "default"); Assert.Equal(HttpStatusCode.BadRequest, response.Status); @@ -179,6 +284,7 @@ public async Task ExecuteAsync_ForwardsNullWhenDefaultGroupDescriptionIsOmitted( "--recovery-plan", "plan1", "--plan-type", "Zonal", "--plan-description", "description", + "--identity-type", "UserAssigned", "--user-assigned-identity", UserAssignedIdentityResourceId); Assert.Equal(HttpStatusCode.OK, response.Status); @@ -214,6 +320,7 @@ public async Task ExecuteAsync_RejectsMalformedUserAssignedIdentityResourceId() "--recovery-plan", "plan1", "--plan-type", "Zonal", "--plan-description", "description", + "--identity-type", "UserAssigned", "--user-assigned-identity", "/subscriptions/id/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account"); Assert.Equal(HttpStatusCode.BadRequest, response.Status); @@ -231,7 +338,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( } [Fact] - public async Task ExecuteAsync_ForwardsNullToCreateSystemAssignedIdentityWhenUserAssignedIdentityIsOmitted() + public async Task ExecuteAsync_ForwardsNullForSystemAssignedIdentity() { Service.CreateRecoveryPlanAsync( "sg1", @@ -249,7 +356,8 @@ public async Task ExecuteAsync_ForwardsNullToCreateSystemAssignedIdentityWhenUse "--service-group", "sg1", "--recovery-plan", "plan1", "--plan-type", "Zonal", - "--plan-description", "description"); + "--plan-description", "description", + "--identity-type", "SystemAssigned"); Assert.Equal(HttpStatusCode.OK, response.Status); await Service.Received(1).CreateRecoveryPlanAsync( @@ -264,6 +372,35 @@ await Service.Received(1).CreateRecoveryPlanAsync( Arg.Any()); } + [Fact] + public async Task ExecuteAsync_RejectsUserAssignedIdentityTypeWithoutResourceId() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "UserAssigned"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--user-assigned-identity is required", response.Message); + } + + [Fact] + public async Task ExecuteAsync_RejectsUserAssignedIdentityForSystemAssignedType() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--user-assigned-identity", UserAssignedIdentityResourceId); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("not allowed", response.Message); + } + [Theory] [InlineData(HttpStatusCode.Forbidden, "Authorization failed")] [InlineData(HttpStatusCode.NotFound, "Service group not found")] @@ -318,4 +455,4 @@ private void ConfigureRequestFailure(HttpStatusCode status, string message) Arg.Any()) .ThrowsAsync(new RequestFailedException((int)status, message)); } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs index f45ce51103..a459373200 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs @@ -111,4 +111,21 @@ public async Task ExecuteAsync_SanitizesRequestFailedException(HttpStatusCode st Assert.Contains(expectedMessage, response.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(providerDetails, response.Message); } -} \ No newline at end of file + + [Fact] + public async Task ExecuteAsync_HandlesUnexpectedServiceError() + { + Service.DeleteRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + var response = await ExecuteCommandAsync(ValidArgs); + + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.StartsWith("Test error", response.Message); + } +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs new file mode 100644 index 0000000000..397df5415c --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using Azure.Mcp.Tools.ResilienceManagement.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Azure.ResourceManager.ResilienceManagement.Models; +using Microsoft.Mcp.Core.Options; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Recovery.Plans; + +public sealed class RecoveryPlanUpdateResourcesCommandTests : CommandUnitTestsBase +{ + private const string RecoveryResourceId = "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/12345678-9012-3456-7890-123456789012"; + private const string OtherRecoveryResourceId = "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/12345678-9012-3456-7890-123456789013"; + private const string ResourcesToUpdate = """ + [{"id":"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/12345678-9012-3456-7890-123456789012","properties":{"recoveryResourceUniqueId":"12345678-9012-3456-7890-123456789012","inclusionState":"Included","selectedProtectionSolutionType":"AzureNative","selectedProtectionSolutionSetting":{"protectionSolutionType":"AzureNative"}}}] + """; + private const string ResourcesToUpdateWithoutId = """ + [{"properties":{"recoveryResourceUniqueId":"12345678-9012-3456-7890-123456789012","inclusionState":"Included"}}] + """; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + Assert.Equal("update-resources", command.Name); + Assert.NotNull(command.Description); + Assert.Contains("includes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("excludes a resource from a recovery plan", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("removes a recovery resource from a recovery plan", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("protection solution type and settings", command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_RequiresAnUpdateOrRemoval() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("at least one", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_ForwardsResourceUpdates() + { + Service.UpdateRecoveryPlanResourcesAsync( + "sg1", + "plan1", + Arg.Is(content => + content.ResourcesToUpdate.Count == 1 && + content.ResourcesToRemove.Count == 0 && + content.ResourcesToUpdate[0].Id.ToString() == RecoveryResourceId && + content.ResourcesToUpdate[0].Properties.InclusionState == ResourceInclusionState.Included && + content.ResourcesToUpdate[0].Properties.SelectedProtectionSolutionType == ResourceProtectionSolutionType.AzureNative && + content.ResourcesToUpdate[0].Properties.SelectedProtectionSolutionSetting != null), + null, + null, + Arg.Any()) + .Returns(Element("Succeeded")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", ResourcesToUpdate); + + var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.RecoveryPlanUpdateResourcesCommandResult); + Assert.Equal("Succeeded", result.Result.GetProperty("status").GetString()); + await Service.Received(1).UpdateRecoveryPlanResourcesAsync( + "sg1", + "plan1", + Arg.Any(), + null, + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_AllowsRemovalOnly() + { + Service.UpdateRecoveryPlanResourcesAsync( + "sg1", + "plan1", + Arg.Is(content => + content.ResourcesToUpdate.Count == 0 && + content.ResourcesToRemove.Count == 1 && + content.ResourcesToRemove[0].ToString() == RecoveryResourceId), + null, + null, + Arg.Any()) + .Returns(Element("Succeeded")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-remove", $"[\"{RecoveryResourceId}\"]"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_RejectsInvalidInclusionState() + { + string updates = ResourcesToUpdate.Replace("Included", "Invalid", StringComparison.Ordinal); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", updates); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("Included, Excluded, or null", response.Message); + } + + [Fact] + public async Task ExecuteAsync_AllowsNullInclusionState() + { + string updates = ResourcesToUpdate.Replace("\"Included\"", "null", StringComparison.Ordinal); + Service.UpdateRecoveryPlanResourcesAsync( + Arg.Any(), + Arg.Any(), + Arg.Is(content => content.ResourcesToUpdate[0].Properties.InclusionState == null), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Element("Succeeded")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", updates); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_AllowsReadOnlyIdToBeOmitted() + { + Service.UpdateRecoveryPlanResourcesAsync( + Arg.Any(), + Arg.Any(), + Arg.Is(content => + content.ResourcesToUpdate.Count == 1 && + content.ResourcesToUpdate[0].Properties.RecoveryResourceUniqueId == "12345678-9012-3456-7890-123456789012"), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Element("Succeeded")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", ResourcesToUpdateWithoutId); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_RejectsMissingRecoveryResourceUniqueId() + { + string updates = ResourcesToUpdate.Replace("\"recoveryResourceUniqueId\":\"12345678-9012-3456-7890-123456789012\",", string.Empty, StringComparison.Ordinal); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", updates); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("recoveryResourceUniqueId GUID", response.Message); + } + + [Fact] + public async Task ExecuteAsync_RejectsIdThatDoesNotMatchRecoveryResourceUniqueId() + { + string updates = ResourcesToUpdate.Replace("recoveryResources/12345678-9012-3456-7890-123456789012", "recoveryResources/12345678-9012-3456-7890-123456789013", StringComparison.Ordinal); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", updates); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("properties.recoveryResourceUniqueId", response.Message); + Assert.Contains("selected recovery plan", response.Message); + } + + [Fact] + public async Task ExecuteAsync_RejectsResourceFromAnotherPlan() + { + string updates = ResourcesToUpdate.Replace("recoveryPlans/plan1", "recoveryPlans/plan2", StringComparison.Ordinal); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", updates); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("selected recovery plan", response.Message); + } + + [Fact] + public async Task ExecuteAsync_RejectsResourceInUpdateAndRemoval() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", ResourcesToUpdate, + "--resources-to-remove", $"[\"{RecoveryResourceId}\",\"{OtherRecoveryResourceId}\"]"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("cannot be updated and removed", response.Message); + } + + [Fact] + public async Task ExecuteAsync_SanitizesProviderFailure() + { + Service.UpdateRecoveryPlanResourcesAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException((int)HttpStatusCode.BadRequest, "provider details")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--resources-to-update", ResourcesToUpdate); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("Verify the resource IDs and protection settings", response.Message); + Assert.DoesNotContain("provider details", response.Message); + } + + private static JsonElement Element(string status) + { + using JsonDocument document = JsonDocument.Parse($$"""{"status":"{{status}}"}"""); + return document.RootElement.Clone(); + } +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index b7dc7c4e4f..d19449b29f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Mcp.Tests; using Microsoft.Mcp.Tests.Client; using Microsoft.Mcp.Tests.Client.Helpers; @@ -160,7 +161,6 @@ public async Task Should_update_recovery_plan() { "name", recoveryPlan } }); var existingPlan = existingResult.AssertProperty("recoveryPlan"); - var existingIdentity = existingPlan.AssertProperty("identity").Clone(); var existingRecoveryGroups = existingPlan .AssertProperty("properties") .AssertProperty("recoveryGroupsSetting"); @@ -185,12 +185,13 @@ public async Task Should_update_recovery_plan() { "service-group", serviceGroup }, { "recovery-plan", recoveryPlan }, { "plan-type", "Zonal" }, - { "plan-description", "Recovery plan created by Azure MCP tests." } + { "plan-description", "Recovery plan created by Azure MCP tests." }, + { "identity-type", "SystemAssigned" } }); var plan = result.AssertProperty("recoveryPlan"); Assert.Equal(recoveryPlan, plan.AssertProperty("name").GetString()); - Assert.True(JsonElement.DeepEquals(existingIdentity, plan.AssertProperty("identity"))); + Assert.Equal("SystemAssigned", plan.AssertProperty("identity").AssertProperty("type").GetString()); var updatedRecoveryGroups = plan .AssertProperty("properties") .AssertProperty("recoveryGroupsSetting"); @@ -205,15 +206,15 @@ public async Task Should_update_recovery_plan() } [Fact] - public async Task Should_delete_recovery_plan_idempotently() + public async Task Should_create_update_and_delete_recovery_plan() { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var recoveryPlan = RegisterOrRetrieveVariable("deleteRecoveryPlanName", $"mcpdel-{Guid.NewGuid().ToString("N")[..8]}"); + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("lifecycleServiceGroupName", "LIFECYCLESERVICEGROUPNAME"); + var recoveryPlan = RegisterOrRetrieveVariable("lifecycleRecoveryPlanName", $"mcplife-{Guid.NewGuid().ToString("N")[..8]}"); bool recoveryPlanExists = false; try { - await CallToolAsync( + var createResult = await CallToolAsync( "resilience_recovery_plan_create", new() { @@ -221,11 +222,58 @@ await CallToolAsync( { "service-group", serviceGroup }, { "recovery-plan", recoveryPlan }, { "plan-type", "Zonal" }, - { "plan-description", "Temporary recovery plan for delete testing." } + { "plan-description", "Recovery plan lifecycle test." }, + { "identity-type", "SystemAssigned" }, + { "default-group-description", "Lifecycle default group" } }); recoveryPlanExists = true; - var deletedResult = await CallToolAsync( + var createdPlan = createResult.AssertProperty("recoveryPlan"); + Assert.Equal(recoveryPlan, createdPlan.AssertProperty("name").GetString()); + Assert.Equal("SystemAssigned", createdPlan.AssertProperty("identity").AssertProperty("type").GetString()); + var createdDefaultGroup = createdPlan + .AssertProperty("properties") + .AssertProperty("recoveryGroupsSetting") + .AssertProperty("defaultGroup") + .AssertProperty("properties"); + var defaultGroupId = createdDefaultGroup.AssertProperty("groupUniqueId").GetString(); + Assert.False(string.IsNullOrEmpty(defaultGroupId)); + Assert.Equal("Lifecycle default group", createdDefaultGroup.AssertProperty("description").GetString()); + + var getResult = await CallToolAsync( + "resilience_recovery_plan_get", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "name", recoveryPlan } + }); + Assert.Equal(recoveryPlan, getResult.AssertProperty("recoveryPlan").AssertProperty("name").GetString()); + + var updateResult = await CallToolAsync( + "resilience_recovery_plan_create", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan }, + { "plan-type", "Zonal" }, + { "plan-description", "Updated recovery plan lifecycle test." }, + { "identity-type", "SystemAssigned" } + }); + var updatedPlan = updateResult.AssertProperty("recoveryPlan"); + Assert.Equal( + "Updated recovery plan lifecycle test.", + updatedPlan.AssertProperty("properties").AssertProperty("planDescription").GetString()); + var updatedDefaultGroup = updatedPlan + .AssertProperty("properties") + .AssertProperty("recoveryGroupsSetting") + .AssertProperty("defaultGroup") + .AssertProperty("properties"); + Assert.Equal(defaultGroupId, updatedDefaultGroup.AssertProperty("groupUniqueId").GetString()); + Assert.Equal("Lifecycle default group", updatedDefaultGroup.AssertProperty("description").GetString()); + + var deleteResult = await CallToolAsync( "resilience_recovery_plan_delete", new() { @@ -234,11 +282,10 @@ await CallToolAsync( { "recovery-plan", recoveryPlan } }); recoveryPlanExists = false; + Assert.True(deleteResult.AssertProperty("deleted").GetBoolean()); + Assert.Equal(recoveryPlan, deleteResult.AssertProperty("recoveryPlan").GetString()); - Assert.True(deletedResult.AssertProperty("deleted").GetBoolean()); - Assert.Equal(recoveryPlan, deletedResult.AssertProperty("recoveryPlan").GetString()); - - var alreadyDeletedResult = await CallToolAsync( + var repeatedDeleteResult = await CallToolAsync( "resilience_recovery_plan_delete", new() { @@ -246,8 +293,7 @@ await CallToolAsync( { "service-group", serviceGroup }, { "recovery-plan", recoveryPlan } }); - - Assert.False(alreadyDeletedResult.AssertProperty("deleted").GetBoolean()); + Assert.False(repeatedDeleteResult.AssertProperty("deleted").GetBoolean()); } finally { @@ -283,6 +329,50 @@ public async Task Should_list_recovery_resources() Assert.Equal(JsonValueKind.Array, result.AssertProperty("recoveryResources").ValueKind); } + [Fact] + public async Task Should_update_recovery_plan_resources() + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); + var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); + + var listedResources = await CallToolAsync( + "resilience_recovery_plan_resource_get", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan } + }); + var resourceSummary = listedResources.AssertProperty("recoveryResources").EnumerateArray().First(); + var resourceName = resourceSummary.AssertProperty("name").GetString(); + Assert.False(string.IsNullOrEmpty(resourceName)); + + var resourceResult = await CallToolAsync( + "resilience_recovery_plan_resource_get", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan }, + { "name", resourceName } + }); + var recoveryResource = resourceResult.AssertProperty("recoveryResource"); + var updatedResource = JsonNode.Parse(recoveryResource.GetRawText())!.AsObject(); + updatedResource["properties"]!["inclusionState"] = "Excluded"; + + var result = await CallToolAsync( + "resilience_recovery_plan_update-resources", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan }, + { "resources-to-update", $"[{updatedResource.ToJsonString()}]" } + }); + + Assert.Equal(JsonValueKind.Array, result.AssertProperty("result").AssertProperty("failedResources").ValueKind); + } + [Fact] public async Task Should_get_recovery_job() { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index 5ba4638078..d9528e4a2f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -53,71 +53,23 @@ public void CreateRecoveryGroupsSetting_ForUpdate_OverridesDefaultGroupDescripti } [Fact] - public void CreateRecoveryPlanIdentity_ForNewPlan_UsesUserAssignedIdentity() + public void CreateRecoveryPlanIdentity_UsesUserAssignedIdentity() { - ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(null, UserAssignedIdentityResourceId); + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(UserAssignedIdentityResourceId); Assert.Equal(ManagedServiceIdentityType.UserAssigned, result.ManagedServiceIdentityType); Assert.Contains(new ResourceIdentifier(UserAssignedIdentityResourceId), result.UserAssignedIdentities.Keys); } [Fact] - public void CreateRecoveryPlanIdentity_ForNewPlanWithoutUserAssignedIdentity_UsesSystemAssignedIdentity() + public void CreateRecoveryPlanIdentity_UsesSystemAssignedIdentityWhenResourceIdIsNull() { - ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(null, null); + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(null); Assert.Equal(ManagedServiceIdentityType.SystemAssigned, result.ManagedServiceIdentityType); Assert.Empty(result.UserAssignedIdentities); } - [Theory] - [InlineData("SystemAssigned")] - [InlineData("UserAssigned")] - [InlineData("SystemAssigned, UserAssigned")] - public void CreateRecoveryPlanIdentity_ForUpdate_PreservesExistingIdentity(string identityType) - { - var existingIdentity = new ManagedServiceIdentity(new ManagedServiceIdentityType(identityType)); - if (identityType.Contains("UserAssigned", StringComparison.Ordinal)) - { - existingIdentity.UserAssignedIdentities.Add( - new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami"), - new UserAssignedIdentity()); - } - - ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(existingIdentity, null); - - Assert.Same(existingIdentity, result); - Assert.Equal(identityType, result.ManagedServiceIdentityType.ToString()); - Assert.Equal(existingIdentity.UserAssignedIdentities, result.UserAssignedIdentities); - } - - [Fact] - public void CreateRecoveryPlanIdentity_ForUpdateWithIdentityOption_RejectsIdentityChange() - { - var existingIdentity = new ManagedServiceIdentity(ManagedServiceIdentityType.SystemAssigned); - - ArgumentException exception = Assert.Throws( - () => ResilienceManagementService.CreateRecoveryPlanIdentity(existingIdentity, UserAssignedIdentityResourceId)); - - Assert.Contains("cannot be changed", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("existing identity is preserved", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void CreateRecoveryPlanIdentity_ForIdempotentUpdate_AcceptsExistingUserAssignedIdentity() - { - var existingIdentity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); - existingIdentity.UserAssignedIdentities.Add( - new ResourceIdentifier(UserAssignedIdentityResourceId), - new UserAssignedIdentity()); - - ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity( - existingIdentity, - UserAssignedIdentityResourceId); - - Assert.Same(existingIdentity, result); - } - [Theory] [InlineData("not-a-resource-id")] [InlineData("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account")] @@ -134,4 +86,4 @@ private static RecoveryGroup CreateGroup(string groupId, int sequenceNumber, str { Properties = new RecoveryGroupProperties(groupId, sequenceNumber, description) }; -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 new file mode 100644 index 0000000000..5e986c88d3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 @@ -0,0 +1,36 @@ +param( + [string] $ResourceGroupName +) + +$ErrorActionPreference = 'Stop' + +$resourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue +if ($null -eq $resourceGroup) { + return +} + +$uniqueSuffix = Get-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName | + Sort-Object Timestamp -Descending | + ForEach-Object { + $outputs = $_.Outputs + if ($outputs -and $outputs.ContainsKey('serviceGroupName')) { + return @( + $outputs['serviceGroupName'].Value, + $outputs['lifecycleServiceGroupName'].Value + ) + } + } | + Select-Object -First 1 + +foreach ($serviceGroupName in $uniqueSuffix) { + if ([string]::IsNullOrWhiteSpace($serviceGroupName)) { + continue + } + + $path = "/providers/Microsoft.Management/serviceGroups/$serviceGroupName`?api-version=2024-02-01-preview" + Write-Host "DELETE $path" + $response = Invoke-AzRestMethod -Method DELETE -Path $path + if ($response.StatusCode -notin @(200, 202, 204, 404)) { + throw "DELETE $path failed with status $($response.StatusCode): $($response.Content)" + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 index 46454c4822..ca4919b1c5 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 @@ -38,6 +38,8 @@ $subscriptionId = $testSettings.SubscriptionId $serviceGroupName = $DeploymentOutputs['SERVICEGROUPNAME'] $usagePlanName = $DeploymentOutputs['USAGEPLANNAME'] $enrollmentName = $DeploymentOutputs['ENROLLMENTNAME'] +$lifecycleEnrollmentName = $DeploymentOutputs['LIFECYCLEENROLLMENTNAME'] +$lifecycleServiceGroupName = $DeploymentOutputs['LIFECYCLESERVICEGROUPNAME'] $goalTemplateName = $DeploymentOutputs['GOALTEMPLATENAME'] $goalAssignmentName = $DeploymentOutputs['GOALASSIGNMENTNAME'] $recoveryPlanName = $DeploymentOutputs['RECOVERYPLANNAME'] @@ -48,6 +50,7 @@ $resilienceApiVersion = '2026-04-01-preview' $serviceGroupId = "/providers/Microsoft.Management/serviceGroups/$serviceGroupName" $serviceGroupResilienceBase = "$serviceGroupId/providers/Microsoft.AzureResilienceManagement" +$lifecycleServiceGroupId = "/providers/Microsoft.Management/serviceGroups/$lifecycleServiceGroupName" function Invoke-ResilienceRestPut { param( @@ -127,6 +130,19 @@ Invoke-ResilienceRestPut -Path $serviceGroupPath -Body @{ } | Out-Null Wait-ResilienceProvisioning -Path $serviceGroupPath +# Create a second enrolled service group without a recovery plan. Lifecycle tests use it +# to exercise create and delete without disturbing the shared plan used by other tests. +$lifecycleServiceGroupPath = "$lifecycleServiceGroupId`?api-version=$serviceGroupApiVersion" +Invoke-ResilienceRestPut -Path $lifecycleServiceGroupPath -Body @{ + properties = @{ + displayName = $lifecycleServiceGroupName + parent = @{ + resourceId = "/providers/Microsoft.Management/serviceGroups/$tenantId" + } + } +} | Out-Null +Wait-ResilienceProvisioning -Path $lifecycleServiceGroupPath + # 2) Add the resource group as a member of the service group so its resources # (e.g. the storage account) surface as goal/recovery resource targets. $membershipPath = "/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Relationships/serviceGroupMember/rhub-rg-member`?api-version=$membershipApiVersion" @@ -145,6 +161,14 @@ Invoke-ResilienceRestPut -Path $enrollmentPath -Body @{ } | Out-Null Wait-ResilienceProvisioning -Path $enrollmentPath +$lifecycleEnrollmentPath = "/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.AzureResilienceManagement/usagePlans/$usagePlanName/enrollments/$lifecycleEnrollmentName`?api-version=$resilienceApiVersion" +Invoke-ResilienceRestPut -Path $lifecycleEnrollmentPath -Body @{ + properties = @{ + serviceGroupId = $lifecycleServiceGroupId + } +} | Out-Null +Wait-ResilienceProvisioning -Path $lifecycleEnrollmentPath + # 4) Create a goal template on the service group. $goalTemplatePath = "$serviceGroupResilienceBase/goalTemplates/$goalTemplateName`?api-version=$resilienceApiVersion" Invoke-ResilienceRestPut -Path $goalTemplatePath -Body @{ diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep index eb91ac738d..47ef80edad 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep @@ -6,6 +6,8 @@ var uniqueSuffix = uniqueString(resourceGroup().id) var usagePlanName = take('up${uniqueSuffix}', 24) var enrollmentName = take('en${uniqueSuffix}', 24) var serviceGroupName = 'sgr${uniqueSuffix}' +var lifecycleEnrollmentName = take('el${uniqueSuffix}', 24) +var lifecycleServiceGroupName = take('sgl${uniqueSuffix}', 24) var goalTemplateName = take('gt${uniqueSuffix}', 24) var goalAssignmentName = take('ga${uniqueSuffix}', 24) var recoveryPlanName = take('rp${uniqueSuffix}', 24) @@ -52,6 +54,8 @@ resource usagePlan 'Microsoft.AzureResilienceManagement/usagePlans@2026-04-01-pr output usagePlanName string = usagePlanName output enrollmentName string = enrollmentName output serviceGroupName string = serviceGroupName +output lifecycleEnrollmentName string = lifecycleEnrollmentName +output lifecycleServiceGroupName string = lifecycleServiceGroupName output goalTemplateName string = goalTemplateName output goalAssignmentName string = goalAssignmentName output recoveryPlanName string = recoveryPlanName From 1eb009edd03d51e551616799ef409c1150e33757 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 12 Aug 2026 20:07:34 +0530 Subject: [PATCH 03/38] copilot comments --- servers/Azure.Mcp.Server/TROUBLESHOOTING.md | 2 +- .../adishijha-recovery-plan-create.yaml | 4 + .../adishijha-recovery-plan-delete.yaml | 3 - ...shijha-recovery-plan-update-resources.yaml | 3 - .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 16 +- .../Plans/RecoveryPlanDeleteCommand.cs | 2 +- .../RecoveryPlanUpdateResourcesCommand.cs | 2 +- .../Plans/RecoveryPlanCreateCommandTests.cs | 23 +++ ...RecoveryPlanUpdateResourcesCommandTests.cs | 2 +- .../ResilienceManagementCommandTests.cs | 44 ++++- .../ResilienceManagementLiveTestCollection.cs | 12 ++ .../ResilienceManagementTestCleanupFixture.cs | 70 ++++++++ .../assets.json | 2 +- .../tests/remove-test-resources-pre.ps1 | 151 +++++++++++++++--- 14 files changed, 290 insertions(+), 46 deletions(-) delete mode 100644 servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml delete mode 100644 servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-update-resources.yaml create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs diff --git a/servers/Azure.Mcp.Server/TROUBLESHOOTING.md b/servers/Azure.Mcp.Server/TROUBLESHOOTING.md index 9ed73c72ca..73457a1c6d 100644 --- a/servers/Azure.Mcp.Server/TROUBLESHOOTING.md +++ b/servers/Azure.Mcp.Server/TROUBLESHOOTING.md @@ -255,7 +255,7 @@ When a newly registered command appears in `all` mode but is missing from consol dotnet test core/Azure.Mcp.Core/tests/Azure.Mcp.Core.Tests/Azure.Mcp.Core.Tests.csproj -- --filter-class '*ConsolidatedToolDiscoveryStrategyTests' ``` -See the [new command guide](docs/new-command.md#consolidated-mode-requirements) for the complete authoring checklist. +See the [new command guide](https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/docs/new-command.md#consolidated-mode-requirements) for the complete authoring checklist. ### VS Code Permission Dialog for Language Model Calls diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml index 5a4f27457f..f75eb172ae 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml @@ -1,3 +1,7 @@ changes: - section: "Features Added" description: "Added the 'azmcp resilience recovery plan create' command to create or fully update a Zonal recovery plan with an explicitly selected system-assigned or user-assigned identity. Updates can switch identity types while preserving existing recovery groups." + - section: "Features Added" + description: "Added the 'azmcp resilience recovery plan delete' command to safely repeat deletion of a recovery plan from an Azure service group and report whether the plan existed." + - section: "Features Added" + description: "Added the 'azmcp resilience recovery plan update-resources' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings." diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml deleted file mode 100644 index 24b3dd880f..0000000000 --- a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-delete.yaml +++ /dev/null @@ -1,3 +0,0 @@ -changes: - - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan delete' command to safely repeat deletion of a recovery plan from an Azure service group and report whether the plan existed." diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-update-resources.yaml b/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-update-resources.yaml deleted file mode 100644 index cd1300d3f6..0000000000 --- a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-update-resources.yaml +++ /dev/null @@ -1,3 +0,0 @@ -changes: - - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan update-resources' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings." diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index c0b91281a0..d0e82b887c 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -906,16 +906,16 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_recovery_job_get | Get the details of recovery job for recovery plan in service group | none | | resilience_recovery_job_resource_get | List all resources (targets) of recovery job for recovery plan in service group | none | | resilience_recovery_job_resource_get | Get the recovery job resource for recovery job of recovery plan in service group | none | -| resilience_recovery_plan_create | Create a Zonal resilience recovery plan in service group with a system-assigned identity, description , and default recovery group described as | none | -| resilience_recovery_plan_create | Create a Zonal resilience recovery plan in service group with user-assigned identity , description , and default recovery group described as | none | -| resilience_recovery_plan_create | Fully update resilience recovery plan in service group , switching it to a system-assigned identity while preserving its Zonal plan type and recovery groups, with description | none | -| resilience_recovery_plan_delete | Delete resilience recovery plan from service group | none | -| resilience_recovery_plan_delete | Remove recovery plan from resilience service group ; report whether it existed | none | +| resilience_recovery_plan_create | Set up a Zonal recovery plan named in service group . Use a system-assigned managed identity, description , and default recovery group description | none | +| resilience_recovery_plan_create | Create Zonal recovery plan in service group and attach user-assigned managed identity . Use for the plan description and for the default recovery group | none | +| resilience_recovery_plan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | +| resilience_recovery_plan_delete | Delete the entire recovery plan from service group | none | +| resilience_recovery_plan_delete | Recovery plan is no longer needed. Delete it from resilience service group | none | | resilience_recovery_plan_get | List all resilience recovery plans in service group | none | | resilience_recovery_plan_get | Get the details of recovery plan in service group | none | -| resilience_recovery_plan_update-resources | Include recovery resource in recovery plan in service group using protection solution and protection settings | none | -| resilience_recovery_plan_update-resources | Exclude recovery resource from recovery plan in service group | none | -| resilience_recovery_plan_update-resources | Remove recovery resource from recovery plan in service group | none | +| resilience_recovery_plan_update-resources | Include recovery resource in recovery plan in service group , using protection solution with settings | none | +| resilience_recovery_plan_update-resources | Keep recovery resource in recovery plan in service group , but mark it as Excluded | none | +| resilience_recovery_plan_update-resources | Remove the individual recovery resource from recovery plan in service group without deleting the recovery plan | none | | resilience_recovery_plan_resource_get | List all resources (members) of recovery plan in service group | none | | resilience_recovery_plan_resource_get | Get the recovery resource for recovery plan in service group | none | | resilience_usageplan_create | Create a resilience usage plan with plan type Basic in resource group | none | diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs index e9d9bf8538..9c8f865f3f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs @@ -15,7 +15,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Id = "e694c9e4-f134-48b2-b6ed-5f6a617d7d8d", Name = "delete", Title = "Delete Resilience Recovery Plan", - Description = "Deletes a resilience recovery plan from an Azure service group. This idempotent operation returns Deleted = false when the recovery plan does not exist.", + Description = "Deletes a resilience recovery plan from an Azure service group. Use this tool to delete a recovery plan and report whether the entire plan existed.", Destructive = true, Idempotent = true, OpenWorld = false, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs index 9094f23b2a..019ebda531 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -18,7 +18,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Id = "ace3cbba-d572-47dc-a452-ab2cb349e17b", Name = "update-resources", Title = "Update Resilience Recovery Plan Resources", - Description = "Includes a recovery resource in an Azure Resilience recovery plan and configures its protection solution type and settings. Excludes a resource from a recovery plan. Removes a recovery resource from a recovery plan. Also assigns recovery groups and managed identities.", + Description = "Includes a recovery resource in a recovery plan in an Azure service group, excludes a recovery resource from a recovery plan in an Azure service group, or removes a recovery resource from a recovery plan in an Azure service group. Updates individual recovery plan resources with a protection solution type and settings, recovery groups, and managed identities.", Destructive = true, Idempotent = true, OpenWorld = false, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs index 50b605799f..35b284b997 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs @@ -63,6 +63,29 @@ public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSu } } + [Fact] + public async Task ExecuteAsync_ReportsMissingIdentityType() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Equal("Missing Required options: --identity-type", response.Message); + await Service.DidNotReceive().CreateRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + TestContext.Current.CancellationToken); + } + [Theory] [InlineData("plan")] [InlineData("1234567890123456789012345")] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs index 397df5415c..4470d65b46 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs @@ -33,7 +33,7 @@ public void Constructor_InitializesCommandCorrectly() Assert.Equal("update-resources", command.Name); Assert.NotNull(command.Description); Assert.Contains("includes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); - Assert.Contains("excludes a resource from a recovery plan", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("excludes a recovery resource from a recovery plan", command.Description, StringComparison.OrdinalIgnoreCase); Assert.Contains("removes a recovery resource from a recovery plan", command.Description, StringComparison.OrdinalIgnoreCase); Assert.Contains("protection solution type and settings", command.Description, StringComparison.OrdinalIgnoreCase); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index d19449b29f..e84dc00df9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Mcp.Tests; +using Microsoft.Mcp.Tests.Attributes; using Microsoft.Mcp.Tests.Client; using Microsoft.Mcp.Tests.Client.Helpers; using Microsoft.Mcp.Tests.Generated.Models; @@ -16,9 +17,16 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests; /// Resources are provisioned by test-resources.bicep + test-resources-post.ps1. /// Drill tools are not part of this toolset (they are onboarded separately). /// -public class ResilienceManagementCommandTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture) +[Collection(ResilienceManagementLiveTestCollection.Name)] +public class ResilienceManagementCommandTests( + ITestOutputHelper output, + TestProxyFixture fixture, + LiveServerFixture liveServerFixture, + ResilienceManagementTestCleanupFixture cleanupFixture) : RecordedCommandTestsBase(output, fixture, liveServerFixture) { + private readonly ResilienceManagementTestCleanupFixture _cleanupFixture = cleanupFixture; + // Prepend the base sanitizers (e.g. WWW-Authenticate) then add tool-specific ones. // Sanitize x-ms-operation-identifier response header which contains the real tenant ID and object ID. public override List HeaderRegexSanitizers => @@ -27,6 +35,14 @@ public class ResilienceManagementCommandTests(ITestOutputHelper output, TestProx new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("x-ms-operation-identifier") { Value = "sanitized" + }), + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("operation-id") + { + Value = "sanitized" + }), + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("Location") + { + Value = "" }) ]; @@ -190,7 +206,7 @@ public async Task Should_update_recovery_plan() }); var plan = result.AssertProperty("recoveryPlan"); - Assert.Equal(recoveryPlan, plan.AssertProperty("name").GetString()); + Assert.EndsWith($"/recoveryPlans/{recoveryPlan}", plan.AssertProperty("id").GetString()); Assert.Equal("SystemAssigned", plan.AssertProperty("identity").AssertProperty("type").GetString()); var updatedRecoveryGroups = plan .AssertProperty("properties") @@ -206,6 +222,7 @@ public async Task Should_update_recovery_plan() } [Fact] + [CustomMatcher(compareBody: false)] public async Task Should_create_update_and_delete_recovery_plan() { var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("lifecycleServiceGroupName", "LIFECYCLESERVICEGROUPNAME"); @@ -229,7 +246,7 @@ public async Task Should_create_update_and_delete_recovery_plan() recoveryPlanExists = true; var createdPlan = createResult.AssertProperty("recoveryPlan"); - Assert.Equal(recoveryPlan, createdPlan.AssertProperty("name").GetString()); + Assert.EndsWith($"/recoveryPlans/{recoveryPlan}", createdPlan.AssertProperty("id").GetString()); Assert.Equal("SystemAssigned", createdPlan.AssertProperty("identity").AssertProperty("type").GetString()); var createdDefaultGroup = createdPlan .AssertProperty("properties") @@ -248,7 +265,9 @@ public async Task Should_create_update_and_delete_recovery_plan() { "service-group", serviceGroup }, { "name", recoveryPlan } }); - Assert.Equal(recoveryPlan, getResult.AssertProperty("recoveryPlan").AssertProperty("name").GetString()); + Assert.EndsWith( + $"/recoveryPlans/{recoveryPlan}", + getResult.AssertProperty("recoveryPlan").AssertProperty("id").GetString()); var updateResult = await CallToolAsync( "resilience_recovery_plan_create", @@ -344,7 +363,8 @@ public async Task Should_update_recovery_plan_resources() { "recovery-plan", recoveryPlan } }); var resourceSummary = listedResources.AssertProperty("recoveryResources").EnumerateArray().First(); - var resourceName = resourceSummary.AssertProperty("name").GetString(); + var resourceId = resourceSummary.AssertProperty("id").GetString(); + var resourceName = resourceId?.Split('/').Last(); Assert.False(string.IsNullOrEmpty(resourceName)); var resourceResult = await CallToolAsync( @@ -357,8 +377,18 @@ public async Task Should_update_recovery_plan_resources() { "name", resourceName } }); var recoveryResource = resourceResult.AssertProperty("recoveryResource"); - var updatedResource = JsonNode.Parse(recoveryResource.GetRawText())!.AsObject(); - updatedResource["properties"]!["inclusionState"] = "Excluded"; + var recoveryResourceUniqueId = recoveryResource + .AssertProperty("properties") + .AssertProperty("recoveryResourceUniqueId") + .GetString(); + var updatedResource = new JsonObject + { + ["properties"] = new JsonObject + { + ["recoveryResourceUniqueId"] = recoveryResourceUniqueId, + ["inclusionState"] = "Excluded" + } + }; var result = await CallToolAsync( "resilience_recovery_plan_update-resources", diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs new file mode 100644 index 0000000000..cbe0a7d1ba --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +namespace Azure.Mcp.Tools.ResilienceManagement.Tests; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ResilienceManagementLiveTestCollection() : ICollectionFixture +{ + public const string Name = "ResilienceManagementLiveTests"; +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs new file mode 100644 index 0000000000..9c74ecca74 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Microsoft.Mcp.Tests.Client.Helpers; +using Microsoft.Mcp.Tests.Helpers; +using Xunit; + +namespace Azure.Mcp.Tools.ResilienceManagement.Tests; + +public sealed class ResilienceManagementTestCleanupFixture() : IAsyncLifetime +{ + public ValueTask InitializeAsync() => ValueTask.CompletedTask; + + public async ValueTask DisposeAsync() + { + if (!LiveTestSettings.TryLoadTestSettings(out var settings) || settings.TestMode == TestMode.Playback) + { + return; + } + + var cleanupScript = Path.Combine(settings.SettingsDirectory, "remove-test-resources-pre.ps1"); + if (!File.Exists(cleanupScript)) + { + Console.Error.WriteLine($"WARNING: Resilience Management cleanup script was not found at '{cleanupScript}'."); + return; + } + + try + { + var startInfo = new ProcessStartInfo("pwsh") + { + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(cleanupScript); + startInfo.ArgumentList.Add("-ResourceGroupName"); + startInfo.ArgumentList.Add(settings.ResourceGroupName); + startInfo.ArgumentList.Add("-TestSettingsPath"); + startInfo.ArgumentList.Add(Path.Combine(settings.SettingsDirectory, LiveTestSettings.TestSettingsFileName)); + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start Resilience Management cleanup."); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + var output = await outputTask; + var error = await errorTask; + + if (!string.IsNullOrWhiteSpace(output)) + { + Console.WriteLine(output); + } + + if (process.ExitCode != 0) + { + Console.Error.WriteLine($"WARNING: Resilience Management cleanup exited with code {process.ExitCode}: {error}"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"WARNING: Resilience Management cleanup failed: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index eaef393e1d..584879231c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", - "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_5ec122e793" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_6ad3aad6b5" } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 index 5e986c88d3..8cd31213f8 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 @@ -1,36 +1,147 @@ param( - [string] $ResourceGroupName + [string] $ResourceGroupName, + [string] $TestSettingsPath = (Join-Path $PSScriptRoot '.testsettings.json') ) $ErrorActionPreference = 'Stop' -$resourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue -if ($null -eq $resourceGroup) { - return +if (!(Test-Path $TestSettingsPath)) { + throw "Test settings file '$TestSettingsPath' was not found." } -$uniqueSuffix = Get-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName | - Sort-Object Timestamp -Descending | - ForEach-Object { - $outputs = $_.Outputs - if ($outputs -and $outputs.ContainsKey('serviceGroupName')) { - return @( - $outputs['serviceGroupName'].Value, - $outputs['lifecycleServiceGroupName'].Value - ) +$testSettings = Get-Content $TestSettingsPath -Raw | ConvertFrom-Json +$outputs = $testSettings.DeploymentOutputs +$subscriptionId = $testSettings.SubscriptionId +$tenantId = $testSettings.TenantId +$serviceGroupApiVersion = '2024-02-01-preview' +$membershipApiVersion = '2023-09-01-preview' +$resilienceApiVersion = '2026-04-01-preview' + +$accessToken = az account get-access-token --tenant $tenantId --resource 'https://management.azure.com/' --query accessToken --output tsv +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($accessToken)) { + throw "Azure CLI could not acquire an Azure Resource Manager token for tenant '$tenantId'." +} + +$requestHeaders = @{ Authorization = "Bearer $accessToken" } + +function Invoke-ManagementRestMethod { + param([string] $Method, [string] $Path) + + $response = Invoke-WebRequest ` + -Method $Method ` + -Uri "https://management.azure.com$Path" ` + -Headers $requestHeaders ` + -SkipHttpErrorCheck + + return [pscustomobject]@{ + StatusCode = [int]$response.StatusCode + Content = $response.Content + } +} + +function Get-OutputValue { + param([string] $Name) + + return $outputs.PSObject.Properties[$Name.ToUpperInvariant()].Value +} + +function Wait-ResourceDeleted { + param([string] $ResourceId, [string] $ApiVersion) + + $path = "${ResourceId}?api-version=$ApiVersion" + for ($attempt = 0; $attempt -lt 60; $attempt++) { + $response = Invoke-ManagementRestMethod -Method GET -Path $path + if ($response.StatusCode -eq 404) { + return } - } | - Select-Object -First 1 -foreach ($serviceGroupName in $uniqueSuffix) { - if ([string]::IsNullOrWhiteSpace($serviceGroupName)) { - continue + if ($response.StatusCode -ne 200) { + throw "GET $path failed with status $($response.StatusCode): $($response.Content)" + } + + Start-Sleep -Seconds 5 + } + + throw "Timed out waiting for '$ResourceId' to be deleted." +} + +function Remove-Resource { + param([string] $ResourceId, [string] $ApiVersion) + + if ([string]::IsNullOrWhiteSpace($ResourceId)) { + return } - $path = "/providers/Microsoft.Management/serviceGroups/$serviceGroupName`?api-version=2024-02-01-preview" + $path = "${ResourceId}?api-version=$ApiVersion" Write-Host "DELETE $path" - $response = Invoke-AzRestMethod -Method DELETE -Path $path + $response = Invoke-ManagementRestMethod -Method DELETE -Path $path if ($response.StatusCode -notin @(200, 202, 204, 404)) { throw "DELETE $path failed with status $($response.StatusCode): $($response.Content)" } + + if ($response.StatusCode -ne 404) { + Wait-ResourceDeleted -ResourceId $ResourceId -ApiVersion $ApiVersion + } +} + +function Remove-RecoveryPlans { + param([string] $ServiceGroupId) + + $collectionPath = "$ServiceGroupId/providers/Microsoft.AzureResilienceManagement/recoveryPlans?api-version=$resilienceApiVersion" + $response = Invoke-ManagementRestMethod -Method GET -Path $collectionPath + if ($response.StatusCode -eq 404) { + return + } + + if ($response.StatusCode -ne 200) { + throw "GET $collectionPath failed with status $($response.StatusCode): $($response.Content)" + } + + $content = $response.Content | ConvertFrom-Json + foreach ($recoveryPlan in $content.value) { + Remove-Resource -ResourceId $recoveryPlan.id -ApiVersion $resilienceApiVersion + } +} + +$serviceGroupName = Get-OutputValue -Name 'serviceGroupName' +$lifecycleServiceGroupName = Get-OutputValue -Name 'lifecycleServiceGroupName' +$usagePlanName = Get-OutputValue -Name 'usagePlanName' +$enrollmentName = Get-OutputValue -Name 'enrollmentName' +$lifecycleEnrollmentName = Get-OutputValue -Name 'lifecycleEnrollmentName' +$goalAssignmentName = Get-OutputValue -Name 'goalAssignmentName' +$goalTemplateName = Get-OutputValue -Name 'goalTemplateName' + +$requiredOutputs = @{ + serviceGroupName = $serviceGroupName + lifecycleServiceGroupName = $lifecycleServiceGroupName + usagePlanName = $usagePlanName + enrollmentName = $enrollmentName + lifecycleEnrollmentName = $lifecycleEnrollmentName + goalAssignmentName = $goalAssignmentName + goalTemplateName = $goalTemplateName +} + +foreach ($requiredOutput in $requiredOutputs.GetEnumerator()) { + if ([string]::IsNullOrWhiteSpace($requiredOutput.Value)) { + throw "Deployment output '$($requiredOutput.Key)' is required for safe ResilienceManagement teardown." + } +} + +$serviceGroupId = "/providers/Microsoft.Management/serviceGroups/$serviceGroupName" +$lifecycleServiceGroupId = "/providers/Microsoft.Management/serviceGroups/$lifecycleServiceGroupName" +$resilienceBase = "$serviceGroupId/providers/Microsoft.AzureResilienceManagement" +$usagePlanId = "/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.AzureResilienceManagement/usagePlans/$usagePlanName" + +Remove-RecoveryPlans -ServiceGroupId $serviceGroupId +Remove-RecoveryPlans -ServiceGroupId $lifecycleServiceGroupId +Remove-Resource -ResourceId "$resilienceBase/goalAssignments/$goalAssignmentName" -ApiVersion $resilienceApiVersion +Remove-Resource -ResourceId "$resilienceBase/goalTemplates/$goalTemplateName" -ApiVersion $resilienceApiVersion +Remove-Resource -ResourceId "$usagePlanId/enrollments/$enrollmentName" -ApiVersion $resilienceApiVersion +Remove-Resource -ResourceId "$usagePlanId/enrollments/$lifecycleEnrollmentName" -ApiVersion $resilienceApiVersion +Remove-Resource -ResourceId "/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Relationships/serviceGroupMember/rhub-rg-member" -ApiVersion $membershipApiVersion + +foreach ($serviceGroupIdToDelete in @($lifecycleServiceGroupId, $serviceGroupId)) { + if ($serviceGroupIdToDelete -notmatch '/$') { + Remove-Resource -ResourceId $serviceGroupIdToDelete -ApiVersion $serviceGroupApiVersion + } } \ No newline at end of file From 49edf174d69abf401836a9e469afc8c4bdd6c2e1 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 12 Aug 2026 20:23:55 +0530 Subject: [PATCH 04/38] up --- .vscode/cspell.json | 9 +++++++++ servers/Azure.Mcp.Server/TROUBLESHOOTING.md | 2 +- .../Recovery/Plans/RecoveryPlanCreateCommandTests.cs | 2 +- .../ResilienceManagementCommandTests.cs | 3 ++- .../Services/ResilienceManagementServiceTests.cs | 2 +- .../tests/remove-test-resources-pre.ps1 | 2 ++ 6 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 15e085502d..e20470761c 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -76,6 +76,7 @@ "appinsights", "aspnet", "assemblyfilters", + "asyncio", "authenticode", "authkey", "autoscale", @@ -95,8 +96,10 @@ "blockstorage", "buildid", "buildtransitive", + "byok", "cctor", "centralus", + "chinacloudapi", "classdef", "classfilters", "cloudhealth", @@ -143,10 +146,12 @@ "fname", "funkyfoo", "gdnbaselines", + "getattr", "globaltool", "glsl", "gmsa", "groq", + "hasattr", "hkcu", "hkey_current_user", "hotmail", @@ -190,6 +195,7 @@ "mystorageaccount", "mysvc", "myuser", + "myvault", "nativeproj", "nettrace", "newdb", @@ -207,6 +213,7 @@ "otel", "otlp", "pipefail", + "pipx", "podsubnet", "postgresdb", "privatelink", @@ -214,6 +221,7 @@ "publicapis", "queryset", "quickstart", + "reindexing", "remotemcp", "reportgenerator", "reporttypes", @@ -255,6 +263,7 @@ "testserver", "testshare", "testsub", + "toplist", "toplevel", "uniqueprefix", "unkown", diff --git a/servers/Azure.Mcp.Server/TROUBLESHOOTING.md b/servers/Azure.Mcp.Server/TROUBLESHOOTING.md index 73457a1c6d..e18b366e50 100644 --- a/servers/Azure.Mcp.Server/TROUBLESHOOTING.md +++ b/servers/Azure.Mcp.Server/TROUBLESHOOTING.md @@ -1068,7 +1068,7 @@ If authentication still fails after switching clouds, check the following: #### Sovereign cloud in Remote (using Azure Container Apps) -When authenicating in remote, the following environment variables need to be set on the container: +When authenticating in remote, the following environment variables need to be set on the container: - AZURE_CLOUD - AzureAd__ClientCredentials__0__TokenExchangeUrl diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs index 35b284b997..e4b7b03a29 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs @@ -17,7 +17,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Recovery.Plans; public sealed class RecoveryPlanCreateCommandTests : CommandUnitTestsBase { - private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami"; + private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testIdentity"; private const string ValidArgs = "--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description --identity-type UserAssigned --user-assigned-identity " + UserAssignedIdentityResourceId + " --default-group-description default"; [Fact] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index e84dc00df9..aacdb5d17a 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// cspell:ignore LIFECYCLESERVICEGROUPNAME using System.Text.Json; using System.Text.Json.Nodes; @@ -226,7 +227,7 @@ public async Task Should_update_recovery_plan() public async Task Should_create_update_and_delete_recovery_plan() { var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("lifecycleServiceGroupName", "LIFECYCLESERVICEGROUPNAME"); - var recoveryPlan = RegisterOrRetrieveVariable("lifecycleRecoveryPlanName", $"mcplife-{Guid.NewGuid().ToString("N")[..8]}"); + var recoveryPlan = RegisterOrRetrieveVariable("lifecycleRecoveryPlanName", $"mcp-lifecycle-{Guid.NewGuid().ToString("N")[..8]}"); bool recoveryPlanExists = false; try diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index d9528e4a2f..f54708f4df 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -11,7 +11,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Services; public sealed class ResilienceManagementServiceTests { - private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami"; + private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testIdentity"; [Fact] public void CreateRecoveryGroupsSetting_ForNewPlan_GeneratesDefaultGroupId() diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 index 8cd31213f8..cbb158c5be 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 @@ -3,6 +3,8 @@ param( [string] $TestSettingsPath = (Join-Path $PSScriptRoot '.testsettings.json') ) +# cspell:ignore rhub + $ErrorActionPreference = 'Stop' if (!(Test-Path $TestSettingsPath)) { From b2b0136c2c82dc4f6bbc3586009fbd395196b9b5 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 12 Aug 2026 21:27:12 +0530 Subject: [PATCH 05/38] up --- eng/tools/VallyEvaluator/src/Program.cs | 23 ++++++- .../tests/VallyUtilitiesTests.cs | 13 ++++ ...=> resilience-recovery-plan-commands.yaml} | 2 +- .../Azure.Mcp.Server/docs/azmcp-commands.md | 4 -- .../src/Resources/consolidated-tools.json | 62 +++++++++---------- .../ResilienceManagementLiveTestCollection.cs | 2 +- .../ResilienceManagementTestCleanupFixture.cs | 2 +- 7 files changed, 68 insertions(+), 40 deletions(-) rename servers/Azure.Mcp.Server/changelog-entries/{adishijha-recovery-plan-create.yaml => resilience-recovery-plan-commands.yaml} (83%) diff --git a/eng/tools/VallyEvaluator/src/Program.cs b/eng/tools/VallyEvaluator/src/Program.cs index 92ebb34a83..24314451d2 100644 --- a/eng/tools/VallyEvaluator/src/Program.cs +++ b/eng/tools/VallyEvaluator/src/Program.cs @@ -8,6 +8,12 @@ namespace VallyEvaluator; internal class Program { + private static readonly IReadOnlyDictionary s_promptNamespaceAliases = + new Dictionary(StringComparer.InvariantCultureIgnoreCase) + { + ["resiliencemanagement"] = "resilience" + }; + public static async Task Main(string[] args) { var configuration = new ConfigurationBuilder() @@ -80,10 +86,11 @@ internal static List GetTestToolNamespaces(BuildInfo buildInfo, PromptDa var lastPeriod = split[1].LastIndexOf('.'); var possibleNamespace = split[1].Substring(lastPeriod + 1).ToLowerInvariant(); + var promptNamespace = ResolvePromptNamespace(possibleNamespace, promptNamespaces); - if (promptNamespaces.Contains(possibleNamespace)) + if (promptNamespace != null) { - results.Add(possibleNamespace); + results.Add(promptNamespace); } else { @@ -94,6 +101,18 @@ internal static List GetTestToolNamespaces(BuildInfo buildInfo, PromptDa return results.ToList(); } + internal static string? ResolvePromptNamespace(string possibleNamespace, IReadOnlySet promptNamespaces) + { + if (promptNamespaces.Contains(possibleNamespace)) + { + return possibleNamespace; + } + + return s_promptNamespaceAliases.TryGetValue(possibleNamespace, out var alias) && promptNamespaces.Contains(alias) + ? alias + : null; + } + private static async Task CreateEvalsAsync(string repoRoot, RunConfiguration configuration, BuildInfo? buildInfo = null) { string promptsPath = string.Empty; diff --git a/eng/tools/VallyEvaluator/tests/VallyUtilitiesTests.cs b/eng/tools/VallyEvaluator/tests/VallyUtilitiesTests.cs index b8a049ecca..9f9eed281b 100644 --- a/eng/tools/VallyEvaluator/tests/VallyUtilitiesTests.cs +++ b/eng/tools/VallyEvaluator/tests/VallyUtilitiesTests.cs @@ -7,6 +7,19 @@ namespace VallyEvaluator.Tests; public class VallyUtilitiesTests { + [Fact] + public void ResolvePromptNamespace_MapsResilienceManagementPackageToResilienceNamespace() + { + IReadOnlySet promptNamespaces = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + "resilience" + }; + + var result = Program.ResolvePromptNamespace("resiliencemanagement", promptNamespaces); + + Assert.Equal("resilience", result); + } + [Fact] public void ReplaceAngleBracketPlaceholders_ReplacesKnownPlaceholder_FromReplacementsDictionary() { diff --git a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml similarity index 83% rename from servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml rename to servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml index f75eb172ae..81ba40c7cd 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/adishijha-recovery-plan-create.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml @@ -2,6 +2,6 @@ changes: - section: "Features Added" description: "Added the 'azmcp resilience recovery plan create' command to create or fully update a Zonal recovery plan with an explicitly selected system-assigned or user-assigned identity. Updates can switch identity types while preserving existing recovery groups." - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan delete' command to safely repeat deletion of a recovery plan from an Azure service group and report whether the plan existed." + description: "Added the 'azmcp resilience recovery plan delete' command to delete a recovery plan from an Azure service group. The command is idempotent and reports whether a plan was deleted." - section: "Features Added" description: "Added the 'azmcp resilience recovery plan update-resources' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 69ffc03840..b1a05c31ab 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3735,10 +3735,6 @@ azmcp resilience recovery plan update-resources --service-group [--resources-to-update ''] \ [--resources-to-remove ''] -# Each update item requires properties.recoveryResourceUniqueId. The read-only id may be omitted; when supplied, it must identify that resource in the selected plan. -# Example update item: -# [{"properties":{"recoveryResourceUniqueId":"","inclusionState":"Included","selectedProtectionSolutionType":"AzureNative","selectedProtectionSolutionSetting":{"protectionSolutionType":"AzureNative"}}}] - # Get a resource (member) of a recovery plan, or list all resources of the plan (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan resource get --subscription \ diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 7ae3eabd9c..45e3ed6ef9 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -888,37 +888,37 @@ ] }, { - "name": "apply_azure_advisor_recommendations", - "description": "Get rules that can help apply Advisor recommendation to create or modify IaaC files (like ARM, Bicep) for Azure resources.", - "toolMetadata": { - "destructive": { - "value": false, - "description": "This tool performs only additive updates without deleting or modifying existing resources." - }, - "idempotent": { - "value": true, - "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." - }, - "openWorld": { - "value": false, - "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." - }, - "readOnly": { - "value": true, - "description": "This tool only performs read operations without modifying any state or data." - }, - "secret": { - "value": false, - "description": "This tool does not handle sensitive or secret information." - }, - "localRequired": { - "value": false, - "description": "This tool is available in both local and remote server modes." - } - }, - "mappedToolList": [ - "advisor_recommendation_apply" - ] + "name": "apply_azure_advisor_recommendations", + "description": "Get rules that can help apply Advisor recommendation to create or modify IaaC files (like ARM, Bicep) for Azure resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "advisor_recommendation_apply" + ] }, { "name": "get_azure_retail_pricing", diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs index cbe0a7d1ba..f551a476c3 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs @@ -9,4 +9,4 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests; public sealed class ResilienceManagementLiveTestCollection() : ICollectionFixture { public const string Name = "ResilienceManagementLiveTests"; -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs index 9c74ecca74..0d6584e854 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs @@ -67,4 +67,4 @@ public async ValueTask DisposeAsync() Console.Error.WriteLine($"WARNING: Resilience Management cleanup failed: {ex.Message}"); } } -} \ No newline at end of file +} From c88432a9abf1f8ba4ef18d16fedb90c4557d2872 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Thu, 13 Aug 2026 12:12:27 +0530 Subject: [PATCH 06/38] up --- .vscode/cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index e20470761c..578b23a0cc 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -227,6 +227,7 @@ "reporttypes", "resourcegroups", "resourcename", + "resiliencemanagement", "resx", "rhtest", "roboto", From 83a731c3439e1cf2e936142d057060cf3b753ced Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Fri, 14 Aug 2026 01:29:05 +0530 Subject: [PATCH 07/38] up --- .../resilience-recovery-plan-commands.yaml | 2 +- .../Azure.Mcp.Server/docs/azmcp-commands.md | 9 +-- .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 2 + .../Plans/RecoveryPlanCreateCommand.cs | 17 +++-- .../Plans/RecoveryPlanDeleteCommand.cs | 13 ---- .../src/Models/RecoveryPlanIdentityKind.cs | 3 +- .../Plans/RecoveryPlanCreateOption.cs | 8 +-- .../Services/IResilienceManagementService.cs | 2 +- .../Services/ResilienceManagementService.cs | 30 +++++++-- .../Plans/RecoveryPlanCreateCommandTests.cs | 63 ++++++++++++++++++- .../Plans/RecoveryPlanDeleteCommandTests.cs | 21 ------- .../ResilienceManagementServiceTests.cs | 60 +++++++++++++++++- 12 files changed, 168 insertions(+), 62 deletions(-) diff --git a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml index 81ba40c7cd..8a0ffc2901 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml @@ -1,6 +1,6 @@ changes: - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan create' command to create or fully update a Zonal recovery plan with an explicitly selected system-assigned or user-assigned identity. Updates can switch identity types while preserving existing recovery groups." + description: "Added the 'azmcp resilience recovery plan create' command to create or fully update a Zonal recovery plan with an explicitly selected system-assigned, user-assigned, or combined identity. Updates can switch identity types while preserving existing recovery groups." - section: "Features Added" description: "Added the 'azmcp resilience recovery plan delete' command to delete a recovery plan from an Azure service group. The command is idempotent and reports whether a plan was deleted." - section: "Features Added" diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index b1a05c31ab..06f48195e4 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3711,17 +3711,18 @@ azmcp resilience recovery plan get --subscription \ --service-group \ [--name ] -# Create or fully update a Zonal resilience recovery plan. An identity is required, and updates can switch between system-assigned and user-assigned identities. +# Create or fully update a Zonal resilience recovery plan. An identity is required, and updates can use system-assigned, user-assigned, or both identity types. The plan description is required on create and preserved when omitted on update. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan create --service-group \ --recovery-plan \ --plan-type Zonal \ - --plan-description \ - --identity-type \ + [--plan-description ] \ + --identity-type \ [--user-assigned-identity ] \ [--default-group-description ] -# Provide --user-assigned-identity only when --identity-type is UserAssigned. +# Provide --user-assigned-identity when --identity-type is UserAssigned or SystemAndUserAssigned. +# Directly replacing one user-assigned identity with another is not currently supported. # Delete a resilience recovery plan. Returns deleted=false when the plan does not exist. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index d0e82b887c..8ccc5b0d8c 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -909,6 +909,8 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_recovery_plan_create | Set up a Zonal recovery plan named in service group . Use a system-assigned managed identity, description , and default recovery group description | none | | resilience_recovery_plan_create | Create Zonal recovery plan in service group and attach user-assigned managed identity . Use for the plan description and for the default recovery group | none | | resilience_recovery_plan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | +| resilience_recovery_plan_create | Update recovery plan in service group to use a user-assigned managed identity | clarification-required | +| resilience_recovery_plan_create | Update recovery plan in service group to use both its system-assigned identity and user-assigned managed identity . Preserve its existing plan settings | none | | resilience_recovery_plan_delete | Delete the entire recovery plan from service group | none | | resilience_recovery_plan_delete | Recovery plan is no longer needed. Delete it from resilience service group | none | | resilience_recovery_plan_get | List all resilience recovery plans in service group | none | diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index 35f1849cf2..6e278f31fb 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -16,9 +16,13 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Name = "create", Title = "Create or Update Resilience Recovery Plan", Description = """ - Create or fully update a Zonal resilience recovery plan in my service group with a required system-assigned - or user-assigned managed identity. Updates can switch between identity types and preserve the default recovery - group ID, additional recovery groups, and omitted default group description. + Create or fully update a Zonal resilience recovery plan in my service group with a required system-assigned, + user-assigned, or combined managed identity. Updates can switch between identity types and preserve the default recovery + group ID, additional recovery groups, and omitted plan or default group descriptions. The plan description is required + when creating a plan. A user-assigned identity update + must include the full identity resource ID. If it is not provided, ask the user for it instead of assuming the + recovery plan's existing user-assigned identity. Directly replacing one user-assigned identity with another is + not currently supported. """, Destructive = true, Idempotent = true, @@ -46,7 +50,7 @@ public override void ValidateOptions(RecoveryPlanCreateOptions options, Validati validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); } - if (options.PlanDescription.Length is < 5 or > 50) + if (options.PlanDescription is not null && options.PlanDescription.Length is < 5 or > 50) { validationResult.Errors.Add("The recovery plan description must be 5 to 50 characters."); } @@ -56,9 +60,9 @@ public override void ValidateOptions(RecoveryPlanCreateOptions options, Validati validationResult.Errors.Add("The default recovery group description must be 5 to 50 characters when specified."); } - if (options.IdentityType == Models.RecoveryPlanIdentityKind.UserAssigned && string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) + if (options.IdentityType != Models.RecoveryPlanIdentityKind.SystemAssigned && string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) { - validationResult.Errors.Add("--user-assigned-identity is required when --identity-type is UserAssigned."); + validationResult.Errors.Add("--user-assigned-identity is required when --identity-type is UserAssigned or SystemAndUserAssigned."); } else if (options.IdentityType == Models.RecoveryPlanIdentityKind.SystemAssigned && !string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) { @@ -86,6 +90,7 @@ public override async Task ExecuteAsync(CommandContext context, options.RecoveryPlan, options.PlanType, options.PlanDescription, + options.IdentityType, options.UserAssignedIdentity, options.DefaultGroupDescription, options.Tenant, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs index 9c8f865f3f..b99c4900ba 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs @@ -28,16 +28,6 @@ public sealed class RecoveryPlanDeleteCommand(ILogger private readonly ILogger _logger = logger; private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService; - public override void ValidateOptions(RecoveryPlanDeleteOptions options, ValidationResult validationResult) - { - base.ValidateOptions(options, validationResult); - - if (options.RecoveryPlan.Length is < 5 or > 24 || !options.RecoveryPlan.All(IsValidRecoveryPlanNameCharacter)) - { - validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); - } - } - public override async Task ExecuteAsync(CommandContext context, RecoveryPlanDeleteOptions options, CancellationToken cancellationToken) { try @@ -64,9 +54,6 @@ public override async Task ExecuteAsync(CommandContext context, return context.Response; } - private static bool IsValidRecoveryPlanNameCharacter(char character) => - character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'; - protected override string GetErrorMessage(Exception ex) => ex switch { RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityKind.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityKind.cs index 699e115226..f68aac0918 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityKind.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityKind.cs @@ -6,5 +6,6 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models; public enum RecoveryPlanIdentityKind { SystemAssigned, - UserAssigned + UserAssigned, + SystemAndUserAssigned } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs index da748ab746..4e5ca683e6 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs @@ -18,13 +18,13 @@ public class RecoveryPlanCreateOptions [Option(Description = "The recovery plan type. Supported value: Zonal. The type cannot be changed after creation.")] public required RecoveryPlanKind PlanType { get; set; } - [Option(Description = "The recovery plan description, from 5 to 50 characters.")] - public required string PlanDescription { get; set; } + [Option(Description = "The recovery plan description, from 5 to 50 characters. Required when creating a plan; on update, the existing description is preserved when omitted.")] + public string? PlanDescription { get; set; } - [Option(Description = "The managed identity type for the recovery plan. Supported values: SystemAssigned and UserAssigned. Specify this on every create or update; updates can switch identity types.")] + [Option(Description = "The managed identity type for the recovery plan. Supported values: SystemAssigned, UserAssigned, and SystemAndUserAssigned. Specify this on every create or update; updates can switch identity types.")] public required RecoveryPlanIdentityKind IdentityType { get; set; } - [Option(Description = "The full resource ID of the user-assigned managed identity. Required when --identity-type is UserAssigned and not allowed when it is SystemAssigned.")] + [Option(Description = "The full resource ID of the user-assigned managed identity. Required when --identity-type is UserAssigned or SystemAndUserAssigned and not allowed when it is SystemAssigned. Direct replacement of an existing user-assigned identity is not supported.")] public string? UserAssignedIdentity { get; set; } [Option(Description = "The default recovery group description, from 5 to 50 characters. On update, the existing description is preserved when omitted.")] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index 25d213bc47..143352911a 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -36,7 +36,7 @@ public interface IResilienceManagementService Task GetRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); - Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string planDescription, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task DeleteRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 3e96504471..9861ef0731 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -396,24 +396,27 @@ public async Task GetRecoveryPlanAsync(string serviceGroup, string return document.RootElement.Clone(); } - public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string planDescription, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); var serviceGroupId = new ResourceIdentifier($"/providers/Microsoft.Management/serviceGroups/{serviceGroup}"); RecoveryPlanCollection recoveryPlans = armClient.GetRecoveryPlans(serviceGroupId); NullableResponse existingPlan = await recoveryPlans.GetIfExistsAsync(recoveryPlan, cancellationToken); + string effectivePlanDescription = ResolveRecoveryPlanDescription( + planDescription, + existingPlan.HasValue ? existingPlan.Value?.Data?.Properties?.PlanDescription : null); RecoveryGroupsSetting? existingRecoveryGroups = existingPlan.HasValue ? existingPlan.Value?.Data?.Properties?.RecoveryGroupsSetting : null; RecoveryGroupsSetting recoveryGroups = CreateRecoveryGroupsSetting(existingRecoveryGroups, defaultGroupDescription); - ManagedServiceIdentity identity = CreateRecoveryPlanIdentity(userAssignedIdentity); + ManagedServiceIdentity identity = CreateRecoveryPlanIdentity(identityType, userAssignedIdentity, existingPlan.HasValue ? existingPlan.Value?.Data?.Identity : null); var data = new RecoveryPlanData { Identity = identity, Properties = new RecoveryPlanProperties( new RecoveryPlanType(planType.ToString()), - planDescription, + effectivePlanDescription, recoveryGroups) }; @@ -427,6 +430,10 @@ public async Task CreateRecoveryPlanAsync(string serviceGroup, stri return document.RootElement.Clone(); } + internal static string ResolveRecoveryPlanDescription(string? planDescription, string? existingPlanDescription) + => planDescription ?? existingPlanDescription + ?? throw new ArgumentException("--plan-description is required when creating a recovery plan.", nameof(planDescription)); + public async Task DeleteRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); @@ -496,15 +503,26 @@ internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroups return recoveryGroups; } - internal static ManagedServiceIdentity CreateRecoveryPlanIdentity(string? userAssignedIdentity) + internal static ManagedServiceIdentity CreateRecoveryPlanIdentity(RecoveryPlanIdentityKind identityType, string? userAssignedIdentity, ManagedServiceIdentity? existingIdentity = null) { - if (string.IsNullOrWhiteSpace(userAssignedIdentity)) + if (identityType == RecoveryPlanIdentityKind.SystemAssigned) { return new ManagedServiceIdentity(ManagedServiceIdentityType.SystemAssigned); } ResourceIdentifier identityResourceId = ParseUserAssignedIdentityResourceId(userAssignedIdentity!); - var identity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); + ManagedServiceIdentityType managedServiceIdentityType = identityType == RecoveryPlanIdentityKind.SystemAndUserAssigned + ? ManagedServiceIdentityType.SystemAssignedUserAssigned + : ManagedServiceIdentityType.UserAssigned; + var identity = new ManagedServiceIdentity(managedServiceIdentityType); + foreach (ResourceIdentifier existingIdentityResourceId in existingIdentity?.UserAssignedIdentities.Keys ?? []) + { + if (existingIdentityResourceId != identityResourceId) + { + throw new ArgumentException("Replacing an existing user-assigned managed identity with a different identity is not currently supported.", nameof(userAssignedIdentity)); + } + } + identity.UserAssignedIdentities.Add(identityResourceId, new UserAssignedIdentity()); return identity; } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs index e4b7b03a29..6023f55064 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs @@ -32,6 +32,7 @@ public void Constructor_InitializesCommandCorrectly() [Theory] [InlineData(ValidArgs, true)] [InlineData("--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description --identity-type SystemAssigned", true)] + [InlineData("--service-group sg1 --recovery-plan plan1 --plan-type Zonal --identity-type SystemAssigned", true)] [InlineData("--service-group sg1 --recovery-plan plan1 --plan-type Zonal --plan-description description", false)] [InlineData("--recovery-plan plan1 --plan-type Zonal --plan-description description --default-group-description default", false)] [InlineData("--service-group sg1 --plan-type Zonal --plan-description description --default-group-description default", false)] @@ -45,7 +46,8 @@ public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSu Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), @@ -79,6 +81,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), @@ -108,6 +111,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), @@ -125,6 +129,7 @@ public async Task ExecuteAsync_AcceptsRecoveryPlanNameBoundaryLengths(string rec recoveryPlan, Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), @@ -169,6 +174,7 @@ public async Task ExecuteAsync_AcceptsPlanDescriptionBoundaryLengths(string plan Arg.Any(), Arg.Any(), planDescription, + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), @@ -213,6 +219,7 @@ public async Task ExecuteAsync_AcceptsDefaultGroupDescriptionBoundaryLengths(str Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), defaultGroupDescription, Arg.Any(), @@ -249,6 +256,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), @@ -264,6 +272,7 @@ public async Task ExecuteAsync_ReturnsRecoveryPlanAndForwardsCompletePutOptions( "plan1", RecoveryPlanKind.Zonal, "description", + RecoveryPlanIdentityKind.UserAssigned, UserAssignedIdentityResourceId, "default", null, @@ -280,6 +289,7 @@ await Service.Received(1).CreateRecoveryPlanAsync( "plan1", RecoveryPlanKind.Zonal, "description", + RecoveryPlanIdentityKind.UserAssigned, UserAssignedIdentityResourceId, "default", null, @@ -295,6 +305,7 @@ public async Task ExecuteAsync_ForwardsNullWhenDefaultGroupDescriptionIsOmitted( "plan1", RecoveryPlanKind.Zonal, "description", + RecoveryPlanIdentityKind.UserAssigned, UserAssignedIdentityResourceId, null, null, @@ -316,6 +327,7 @@ await Service.Received(1).CreateRecoveryPlanAsync( "plan1", RecoveryPlanKind.Zonal, "description", + RecoveryPlanIdentityKind.UserAssigned, UserAssignedIdentityResourceId, null, null, @@ -353,6 +365,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), @@ -368,6 +381,7 @@ public async Task ExecuteAsync_ForwardsNullForSystemAssignedIdentity() "plan1", RecoveryPlanKind.Zonal, "description", + RecoveryPlanIdentityKind.SystemAssigned, null, null, null, @@ -388,6 +402,7 @@ await Service.Received(1).CreateRecoveryPlanAsync( "plan1", RecoveryPlanKind.Zonal, "description", + RecoveryPlanIdentityKind.SystemAssigned, null, null, null, @@ -396,14 +411,54 @@ await Service.Received(1).CreateRecoveryPlanAsync( } [Fact] - public async Task ExecuteAsync_RejectsUserAssignedIdentityTypeWithoutResourceId() + public async Task ExecuteAsync_ForwardsSystemAndUserAssignedIdentity() + { + Service.CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + RecoveryPlanIdentityKind.SystemAndUserAssigned, + UserAssignedIdentityResourceId, + null, + null, + null, + Arg.Any()) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAndUserAssigned", + "--user-assigned-identity", UserAssignedIdentityResourceId); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + RecoveryPlanIdentityKind.SystemAndUserAssigned, + UserAssignedIdentityResourceId, + null, + null, + null, + Arg.Any()); + } + + [Theory] + [InlineData(RecoveryPlanIdentityKind.UserAssigned)] + [InlineData(RecoveryPlanIdentityKind.SystemAndUserAssigned)] + public async Task ExecuteAsync_RejectsIdentityTypeWithoutRequiredUserAssignedResourceId(RecoveryPlanIdentityKind identityType) { var response = await ExecuteCommandAsync( "--service-group", "sg1", "--recovery-plan", "plan1", "--plan-type", "Zonal", "--plan-description", "description", - "--identity-type", "UserAssigned"); + "--identity-type", identityType.ToString()); Assert.Equal(HttpStatusCode.BadRequest, response.Status); Assert.Contains("--user-assigned-identity is required", response.Message); @@ -448,6 +503,7 @@ public async Task ExecuteAsync_HandlesServiceErrors() Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), @@ -471,6 +527,7 @@ private void ConfigureRequestFailure(HttpStatusCode status, string message) Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs index a459373200..79ed665c41 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs @@ -49,27 +49,6 @@ public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSu Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); } - [Theory] - [InlineData("plan")] - [InlineData("1234567890123456789012345")] - [InlineData("bad_name")] - [InlineData("../plan")] - public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName(string recoveryPlan) - { - var response = await ExecuteCommandAsync( - "--service-group", "sg1", - "--recovery-plan", recoveryPlan); - - Assert.Equal(HttpStatusCode.BadRequest, response.Status); - Assert.Contains("5 to 24 characters", response.Message); - await Service.DidNotReceive().DeleteRecoveryPlanAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - TestContext.Current.CancellationToken); - } - [Theory] [InlineData(true)] [InlineData(false)] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index f54708f4df..ab9e55aa56 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Azure.Core; +using Azure.Mcp.Tools.ResilienceManagement.Models; using Azure.Mcp.Tools.ResilienceManagement.Services; using Azure.ResourceManager.Models; using Azure.ResourceManager.ResilienceManagement.Models; @@ -52,19 +53,74 @@ public void CreateRecoveryGroupsSetting_ForUpdate_OverridesDefaultGroupDescripti Assert.Equal("Updated default group", result.DefaultGroup.Properties?.Description); } + [Fact] + public void ResolveRecoveryPlanDescription_ForUpdate_PreservesExistingDescription() + { + string result = ResilienceManagementService.ResolveRecoveryPlanDescription(null, "Existing description"); + + Assert.Equal("Existing description", result); + } + + [Fact] + public void ResolveRecoveryPlanDescription_ForCreate_RequiresDescription() + { + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ResolveRecoveryPlanDescription(null, null)); + + Assert.Contains("--plan-description is required", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void CreateRecoveryPlanIdentity_UsesUserAssignedIdentity() { - ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(UserAssignedIdentityResourceId); + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(RecoveryPlanIdentityKind.UserAssigned, UserAssignedIdentityResourceId); Assert.Equal(ManagedServiceIdentityType.UserAssigned, result.ManagedServiceIdentityType); Assert.Contains(new ResourceIdentifier(UserAssignedIdentityResourceId), result.UserAssignedIdentities.Keys); } + [Fact] + public void CreateRecoveryPlanIdentity_AllowsExistingUserAssignedIdentity() + { + var identityResourceId = new ResourceIdentifier(UserAssignedIdentityResourceId); + var existingIdentity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); + existingIdentity.UserAssignedIdentities.Add(identityResourceId, new UserAssignedIdentity()); + + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(RecoveryPlanIdentityKind.UserAssigned, UserAssignedIdentityResourceId, existingIdentity); + + Assert.Equal(ManagedServiceIdentityType.UserAssigned, result.ManagedServiceIdentityType); + Assert.NotNull(result.UserAssignedIdentities[identityResourceId]); + } + + [Fact] + public void CreateRecoveryPlanIdentity_RejectsReplacingExistingUserAssignedIdentity() + { + var existingIdentityResourceId = new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/oldIdentity"); + var existingIdentity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); + existingIdentity.UserAssignedIdentities.Add(existingIdentityResourceId, new UserAssignedIdentity()); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.CreateRecoveryPlanIdentity(RecoveryPlanIdentityKind.UserAssigned, UserAssignedIdentityResourceId, existingIdentity)); + + Assert.Contains("not currently supported", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CreateRecoveryPlanIdentity_UsesSystemAndUserAssignedIdentity() + { + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(RecoveryPlanIdentityKind.SystemAndUserAssigned, UserAssignedIdentityResourceId); + + Assert.Equal(ManagedServiceIdentityType.SystemAssignedUserAssigned, result.ManagedServiceIdentityType); + Assert.NotNull(result.UserAssignedIdentities[new ResourceIdentifier(UserAssignedIdentityResourceId)]); + } + [Fact] public void CreateRecoveryPlanIdentity_UsesSystemAssignedIdentityWhenResourceIdIsNull() { - ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(null); + var existingIdentity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); + existingIdentity.UserAssignedIdentities.Add(new ResourceIdentifier(UserAssignedIdentityResourceId), new UserAssignedIdentity()); + + ManagedServiceIdentity result = ResilienceManagementService.CreateRecoveryPlanIdentity(RecoveryPlanIdentityKind.SystemAssigned, null, existingIdentity); Assert.Equal(ManagedServiceIdentityType.SystemAssigned, result.ManagedServiceIdentityType); Assert.Empty(result.UserAssignedIdentities); From 2fd2030da40af59749b64db2cae1b03af5746840 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Sun, 16 Aug 2026 23:49:03 +0530 Subject: [PATCH 08/38] Merge remote-tracking branch 'upstream/main' into adishi/ROupdate --- .../resilience-recovery-plan-commands.yaml | 2 +- .../Azure.Mcp.Server/docs/azmcp-commands.md | 2 + .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 9 +- .../Plans/RecoveryPlanCreateCommand.cs | 10 +- .../Plans/RecoveryPlanDeleteCommand.cs | 6 + .../RecoveryPlanUpdateResourcesCommand.cs | 20 +- .../Recovery/Plans/RecoveryPlanValidation.cs | 20 + .../ResilienceManagementJsonContext.cs | 6 + .../src/Models/RecoveryPlanGroupInfo.cs | 11 + .../src/Models/RecoveryPlanIdentityInfo.cs | 10 + .../src/Models/RecoveryPlanInfo.cs | 17 + .../src/Models/RecoveryPlanKind.cs | 1 - .../RecoveryPlanUpdateResourcesError.cs | 10 + ...coveryPlanUpdateResourcesFailedResource.cs | 12 + .../RecoveryPlanUpdateResourcesResult.cs | 9 + .../Plans/RecoveryPlanCreateOption.cs | 4 +- .../RecoveryPlanUpdateResourcesOption.cs | 12 +- .../Services/IResilienceManagementService.cs | 4 +- .../Services/ResilienceManagementService.cs | 168 +++++++- .../Plans/RecoveryPlanCreateCommandTests.cs | 20 +- .../Plans/RecoveryPlanDeleteCommandTests.cs | 18 + ...RecoveryPlanUpdateResourcesCommandTests.cs | 40 +- .../ResilienceManagementCommandTests.cs | 3 +- .../ResilienceManagementServiceTests.cs | 382 ++++++++++++++++++ 24 files changed, 735 insertions(+), 61 deletions(-) create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidation.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInfo.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityInfo.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanInfo.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesError.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesFailedResource.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesResult.cs diff --git a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml index 8a0ffc2901..628ce4b7a2 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml @@ -4,4 +4,4 @@ changes: - section: "Features Added" description: "Added the 'azmcp resilience recovery plan delete' command to delete a recovery plan from an Azure service group. The command is idempotent and reports whether a plan was deleted." - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan update-resources' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings." + description: "Added the 'azmcp resilience recovery plan update-resources' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings. The command validates mandatory protection settings before first inclusion while preserving existing settings on sparse updates." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 86281ea318..ab2568e13c 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3744,6 +3744,8 @@ azmcp resilience recovery plan delete --service-group \ --recovery-plan # Configure recovery-plan resource inclusions, exclusions, removals, recovery groups, identities, and protection settings. At least one JSON array is required. +# First inclusion requires matching protection type and settings. CustomRunbook requires failover and reprotect runbook resource IDs. +# AzureSiteRecovery is supported for virtual machines and requires disk reprotect details. Existing configuration is preserved on sparse updates. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan update-resources --service-group \ --recovery-plan \ diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index bb610bf760..2b9c1d69cd 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -918,9 +918,12 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_recovery_plan_delete | Recovery plan is no longer needed. Delete it from resilience service group | none | | resilience_recovery_plan_get | List all resilience recovery plans in service group | none | | resilience_recovery_plan_get | Get the details of recovery plan in service group | none | -| resilience_recovery_plan_update-resources | Include recovery resource in recovery plan in service group , using protection solution with settings | none | -| resilience_recovery_plan_update-resources | Keep recovery resource in recovery plan in service group , but mark it as Excluded | none | -| resilience_recovery_plan_update-resources | Remove the individual recovery resource from recovery plan in service group without deleting the recovery plan | none | +| resilience_recovery_plan_update-resources | Include and configure recovery resource in recovery plan in service group with selected protection solution type and settings | none | +| resilience_recovery_plan_update-resources | Add recovery resource to recovery plan in service group . Protect it with CustomRunbook using failover runbook and reprotect runbook | none | +| resilience_recovery_plan_update-resources | Include virtual machine recovery resource in recovery plan in service group using AzureSiteRecovery protection settings with disk reprotection, staging storage, and a test failover virtual network | none | +| resilience_recovery_plan_update-resources | Include recovery resource in recovery plan in service group , but I have not chosen CustomRunbook or AzureSiteRecovery protection settings | clarification-required | +| resilience_recovery_plan_update-resources | Keep recovery resource in recovery plan in service group , but exclude it from recovery operations | none | +| resilience_recovery_plan_update-resources | Update recovery plan in service group by removing recovery resource from its resource membership while retaining the recovery plan and its other recovery resources | none | | resilience_recovery_plan_resource_get | List all resources (members) of recovery plan in service group | none | | resilience_recovery_plan_resource_get | Get the recovery resource for recovery plan in service group | none | | resilience_usageplan_create | Create a resilience usage plan with plan type Basic in resource group | none | diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index 6e278f31fb..73b056b0f3 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -45,10 +45,7 @@ public override void ValidateOptions(RecoveryPlanCreateOptions options, Validati validationResult.Errors.Add("Only Zonal recovery plans are currently supported."); } - if (options.RecoveryPlan.Length is < 5 or > 24 || !options.RecoveryPlan.All(IsValidRecoveryPlanNameCharacter)) - { - validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); - } + RecoveryPlanValidation.ValidateName(options.RecoveryPlan, validationResult); if (options.PlanDescription is not null && options.PlanDescription.Length is < 5 or > 50) { @@ -112,9 +109,6 @@ public override async Task ExecuteAsync(CommandContext context, return context.Response; } - private static bool IsValidRecoveryPlanNameCharacter(char character) => - character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'; - protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch { ArgumentException => HttpStatusCode.BadRequest, @@ -135,5 +129,5 @@ private static bool IsValidRecoveryPlanNameCharacter(char character) => _ => base.GetErrorMessage(ex) }; - public record RecoveryPlanCreateCommandResult(System.Text.Json.JsonElement RecoveryPlan); + public record RecoveryPlanCreateCommandResult(Models.RecoveryPlanInfo RecoveryPlan); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs index b99c4900ba..f6fb399589 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanDeleteCommand.cs @@ -28,6 +28,12 @@ public sealed class RecoveryPlanDeleteCommand(ILogger private readonly ILogger _logger = logger; private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService; + public override void ValidateOptions(RecoveryPlanDeleteOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + RecoveryPlanValidation.ValidateName(options.RecoveryPlan, validationResult); + } + public override async Task ExecuteAsync(CommandContext context, RecoveryPlanDeleteOptions options, CancellationToken cancellationToken) { try diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs index 019ebda531..a05539be3b 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -5,6 +5,7 @@ using System.Net; using System.Text.Json; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Models; using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; using Azure.Mcp.Tools.ResilienceManagement.Services; using Azure.ResourceManager.ResilienceManagement.Models; @@ -18,7 +19,14 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Id = "ace3cbba-d572-47dc-a452-ab2cb349e17b", Name = "update-resources", Title = "Update Resilience Recovery Plan Resources", - Description = "Includes a recovery resource in a recovery plan in an Azure service group, excludes a recovery resource from a recovery plan in an Azure service group, or removes a recovery resource from a recovery plan in an Azure service group. Updates individual recovery plan resources with a protection solution type and settings, recovery groups, and managed identities.", + Description = """ + Includes a recovery resource in a recovery plan in an Azure service group, excludes a recovery resource from a recovery + plan in an Azure service group, or removes a recovery resource from a recovery plan in an Azure service group. For + CustomRunbook inclusion, configure failover and reprotect runbooks. For AzureSiteRecovery inclusion, configure disk + reprotection, staging storage, and a test failover virtual network. Removing an individual recovery resource updates the + plan's resource membership while retaining the recovery plan and its other recovery resources. Validates the protection + solution type and settings before first inclusion and preserves existing settings on sparse updates. + """, Destructive = true, Idempotent = true, OpenWorld = false, @@ -36,10 +44,7 @@ public override void ValidateOptions(RecoveryPlanUpdateResourcesOptions options, { base.ValidateOptions(options, validationResult); - if (options.RecoveryPlan.Length is < 5 or > 24 || !options.RecoveryPlan.All(IsValidRecoveryPlanNameCharacter)) - { - validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); - } + RecoveryPlanValidation.ValidateName(options.RecoveryPlan, validationResult); try { @@ -203,9 +208,6 @@ private static void ValidateRecoveryResourceId(string id, string serviceGroup, s } } - private static bool IsValidRecoveryPlanNameCharacter(char character) => - character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'; - protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch { ArgumentException => HttpStatusCode.BadRequest, @@ -226,5 +228,5 @@ private static bool IsValidRecoveryPlanNameCharacter(char character) => _ => base.GetErrorMessage(ex) }; - public sealed record RecoveryPlanUpdateResourcesCommandResult(JsonElement Result); + public sealed record RecoveryPlanUpdateResourcesCommandResult(RecoveryPlanUpdateResourcesResult Result); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidation.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidation.cs new file mode 100644 index 0000000000..b62a38e956 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidation.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; + +internal static class RecoveryPlanValidation +{ + public static void ValidateName(string recoveryPlan, ValidationResult validationResult) + { + if (recoveryPlan.Length is < 5 or > 24 || !recoveryPlan.All(IsValidNameCharacter)) + { + validationResult.Errors.Add("The recovery plan name must be 5 to 24 characters and contain only ASCII letters, numbers, or hyphens."); + } + } + + private static bool IsValidNameCharacter(char character) => + character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'; +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs index d02927110a..be28eff80d 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs @@ -42,8 +42,14 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands; [JsonSerializable(typeof(UsagePlanEnrollmentInfoSystemData))] [JsonSerializable(typeof(RecoveryPlanGetCommand.RecoveryPlanGetCommandResult))] [JsonSerializable(typeof(RecoveryPlanCreateCommand.RecoveryPlanCreateCommandResult))] +[JsonSerializable(typeof(RecoveryPlanInfo))] +[JsonSerializable(typeof(RecoveryPlanIdentityInfo))] +[JsonSerializable(typeof(RecoveryPlanGroupInfo))] [JsonSerializable(typeof(RecoveryPlanDeleteCommand.RecoveryPlanDeleteCommandResult))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesCommand.RecoveryPlanUpdateResourcesCommandResult))] +[JsonSerializable(typeof(RecoveryPlanUpdateResourcesResult))] +[JsonSerializable(typeof(RecoveryPlanUpdateResourcesFailedResource))] +[JsonSerializable(typeof(RecoveryPlanUpdateResourcesError))] [JsonSerializable(typeof(RecoveryResourceGetCommand.RecoveryResourceGetCommandResult))] [JsonSerializable(typeof(RecoveryJobGetCommand.RecoveryJobGetCommandResult))] [JsonSerializable(typeof(RecoveryJobResourceGetCommand.RecoveryJobResourceGetCommandResult))] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInfo.cs new file mode 100644 index 0000000000..4c913649ff --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanGroupInfo( + [property: JsonPropertyName("groupUniqueId")] string GroupUniqueId, + [property: JsonPropertyName("orderId")] int OrderId, + [property: JsonPropertyName("description")] string Description); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityInfo.cs new file mode 100644 index 0000000000..855e981b50 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityInfo.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanIdentityInfo( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("userAssignedIdentityResourceIds")] IReadOnlyList UserAssignedIdentityResourceIds); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanInfo.cs new file mode 100644 index 0000000000..6d50657654 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanInfo.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanInfo( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("planType")] string PlanType, + [property: JsonPropertyName("planDescription")] string PlanDescription, + [property: JsonPropertyName("provisioningState")] string? ProvisioningState, + [property: JsonPropertyName("planState")] string? PlanState, + [property: JsonPropertyName("identity")] RecoveryPlanIdentityInfo? Identity, + [property: JsonPropertyName("defaultGroup")] RecoveryPlanGroupInfo? DefaultGroup, + [property: JsonPropertyName("additionalGroups")] IReadOnlyList AdditionalGroups); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs index 69f5edfa35..8f966debd3 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanKind.cs @@ -5,6 +5,5 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models; public enum RecoveryPlanKind { - Regional, Zonal } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesError.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesError.cs new file mode 100644 index 0000000000..248c6fee56 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesError.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanUpdateResourcesError( + [property: JsonPropertyName("code")] string? Code, + [property: JsonPropertyName("message")] string? Message); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesFailedResource.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesFailedResource.cs new file mode 100644 index 0000000000..a5f62935d6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesFailedResource.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanUpdateResourcesFailedResource( + [property: JsonPropertyName("recoveryResourceId")] string RecoveryResourceId, + [property: JsonPropertyName("recoveryResourceUniqueId")] string RecoveryResourceUniqueId, + [property: JsonPropertyName("azureResourceId")] string? AzureResourceId, + [property: JsonPropertyName("error")] RecoveryPlanUpdateResourcesError? Error); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesResult.cs new file mode 100644 index 0000000000..4a29052a41 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesResult.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanUpdateResourcesResult( + [property: JsonPropertyName("failedResources")] IReadOnlyList FailedResources); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs index 4e5ca683e6..a7586712a9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs @@ -7,9 +7,9 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; -public class RecoveryPlanCreateOptions +public sealed class RecoveryPlanCreateOptions { - [Option(Description = "The name of the Azure service group that owns the recovery plan.")] + [Option(Description = ResilienceManagementOptionDescriptions.ServiceGroup)] public required string ServiceGroup { get; set; } [Option(Description = "The name of the recovery plan to create or fully update.")] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs index ad9c36100b..4f4b12bc8e 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs @@ -8,13 +8,21 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; public sealed class RecoveryPlanUpdateResourcesOptions { - [Option(Description = "The name of the Azure service group that owns the recovery plan.")] + [Option(Description = ResilienceManagementOptionDescriptions.ServiceGroup)] public required string ServiceGroup { get; set; } [Option(Description = "The name of the recovery plan whose resources will be updated.")] public required string RecoveryPlan { get; set; } - [Option(Description = "A JSON array of recovery resources to include, exclude, or configure. Each item must contain a properties object with recoveryResourceUniqueId. The read-only id may be omitted; when supplied, it must match the unique ID and selected recovery plan. Supported caller-controlled properties include inclusionState, selectedProtectionSolutionType, selectedProtectionSolutionSetting, recoveryGroupId, and associatedIdentity.")] + [Option(Description = + "A JSON array of recovery resources to include, exclude, or configure. " + + "Each item must contain properties.recoveryResourceUniqueId. " + + "First inclusion and re-inclusion require matching selectedProtectionSolutionType and selectedProtectionSolutionSetting. " + + "CustomRunbook requires failoverAction.resourceId and reprotectAction.resourceId values that identify Microsoft.Automation/automationAccounts/runbooks resources. " + + "AzureSiteRecovery is supported for Microsoft.Compute/virtualMachines resources and requires Microsoft.Compute/disks and Microsoft.Storage/storageAccounts IDs in diskReprotectInputDetails; " + + "AzureSiteRecovery also requires testFailoverParams.networkResourceId identifying a Microsoft.Network/virtualNetworks resource. " + + "The service preserves existing settings on sparse updates and permits only inclusionState changes while the resource is excluded. " + + "recoveryGroupId and associatedIdentity are optional.")] public string? ResourcesToUpdate { get; set; } [Option(Description = "A JSON array of full recovery-resource IDs to remove from the recovery plan.")] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index 143352911a..f0beb96c4b 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -36,11 +36,11 @@ public interface IResilienceManagementService Task GetRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); - Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task DeleteRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); - Task UpdateRecoveryPlanResourcesAsync(string serviceGroup, string recoveryPlan, UpdateRecoveryResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task UpdateRecoveryPlanResourcesAsync(string serviceGroup, string recoveryPlan, UpdateRecoveryResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task> ListRecoveryResourcesAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 9861ef0731..4b5d14d956 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -396,7 +396,7 @@ public async Task GetRecoveryPlanAsync(string serviceGroup, string return document.RootElement.Clone(); } - public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); @@ -426,8 +426,41 @@ public async Task CreateRecoveryPlanAsync(string serviceGroup, stri data, cancellationToken); - using JsonDocument document = JsonDocument.Parse(operation.GetRawResponse().Content.ToMemory()); - return document.RootElement.Clone(); + return CreateRecoveryPlanInfo(operation.Value.Data); + } + + internal static RecoveryPlanInfo CreateRecoveryPlanInfo(RecoveryPlanData recoveryPlan) + { + RecoveryPlanProperties? properties = recoveryPlan.Properties; + ManagedServiceIdentity? identity = recoveryPlan.Identity; + RecoveryGroupsSetting? groups = properties?.RecoveryGroupsSetting; + RecoveryPlanIdentityInfo? identityInfo = identity is null + ? null + : new RecoveryPlanIdentityInfo( + identity.ManagedServiceIdentityType.ToString(), + identity.UserAssignedIdentities.Keys + .Select(resourceId => resourceId.ToString()) + .Order(StringComparer.OrdinalIgnoreCase) + .ToList()); + + return new RecoveryPlanInfo( + recoveryPlan.Id?.ToString() ?? string.Empty, + recoveryPlan.Name ?? string.Empty, + properties?.PlanType.ToString() ?? string.Empty, + properties?.PlanDescription ?? string.Empty, + properties?.ProvisioningState?.ToString(), + properties?.PlanState?.ToString(), + identityInfo, + CreateRecoveryPlanGroupInfo(groups?.DefaultGroup), + groups?.AdditionalGroups.Select(CreateRecoveryPlanGroupInfo).OfType().ToList() ?? []); + } + + private static RecoveryPlanGroupInfo? CreateRecoveryPlanGroupInfo(RecoveryGroup? group) + { + RecoveryGroupProperties? properties = group?.Properties; + return properties is null + ? null + : new RecoveryPlanGroupInfo(properties.GroupUniqueId, properties.OrderId, properties.Description); } internal static string ResolveRecoveryPlanDescription(string? planDescription, string? existingPlanDescription) @@ -457,32 +490,143 @@ public async Task DeleteRecoveryPlanAsync(string serviceGroup, string reco } } - public async Task UpdateRecoveryPlanResourcesAsync(string serviceGroup, string recoveryPlan, UpdateRecoveryResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + public async Task UpdateRecoveryPlanResourcesAsync(string serviceGroup, string recoveryPlan, UpdateRecoveryResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); var recoveryPlanId = RecoveryPlanResource.CreateResourceIdentifier(serviceGroup, recoveryPlan); RecoveryPlanResource recoveryPlanResource = await armClient.GetRecoveryPlanResource(recoveryPlanId).GetAsync(cancellationToken); + RecoveryMembersCollection recoveryMembers = recoveryPlanResource.GetAllRecoveryMembers(); + foreach (RecoveryMembersData requestedResource in content.ResourcesToUpdate) + { + Response existingResource = await recoveryMembers.GetAsync( + requestedResource.Properties.RecoveryResourceUniqueId, + cancellationToken); + ValidateRecoveryResourceUpdate(requestedResource, existingResource.Value.Data); + } + ArmOperation operation = await recoveryPlanResource.UpdateResourcesAsync( WaitUntil.Completed, Guid.NewGuid().ToString(), content, cancellationToken); - if (operation.Value.FailedResources.Count == 0) + return CreateRecoveryPlanUpdateResourcesResult(operation.Value.FailedResources); + } + + internal static RecoveryPlanUpdateResourcesResult CreateRecoveryPlanUpdateResourcesResult(IEnumerable failedResources) + { + List resources = failedResources.Select(resource => + { + RecoveryResourceProperties? properties = resource.Properties; + ResponseError? error = properties?.ErrorDetails; + RecoveryPlanUpdateResourcesError? resultError = error is null + ? null + : new RecoveryPlanUpdateResourcesError(error.Code, error.Message); + + return new RecoveryPlanUpdateResourcesFailedResource( + resource.Id?.ToString() ?? string.Empty, + properties?.RecoveryResourceUniqueId ?? resource.Name ?? string.Empty, + properties?.ResourceId?.ToString(), + resultError); + }).ToList(); + + return new RecoveryPlanUpdateResourcesResult(resources); + } + + internal static void ValidateRecoveryResourceUpdate(RecoveryMembersData requestedResource, RecoveryMembersData existingResource) + { + RecoveryResourceProperties requested = requestedResource.Properties + ?? throw new ArgumentException("Each recovery resource update must contain properties."); + RecoveryResourceProperties existing = existingResource.Properties + ?? throw new ArgumentException($"Recovery resource '{requested.RecoveryResourceUniqueId}' has no existing properties."); + ResourceInclusionState? effectiveInclusionState = requested.InclusionState ?? existing.InclusionState; + if (effectiveInclusionState == ResourceInclusionState.Excluded) + { + return; + } + + if (effectiveInclusionState != ResourceInclusionState.Included) { - using JsonDocument emptyResult = JsonDocument.Parse("""{"failedResources":[]}"""); - return emptyResult.RootElement.Clone(); + return; } - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream)) + ResourceProtectionSolutionType? effectiveSolutionType = IsNullOrNone(requested.SelectedProtectionSolutionType) + ? existing.SelectedProtectionSolutionType + : requested.SelectedProtectionSolutionType; + ResourceBaseProtectionSolutionSetting? effectiveSolutionSetting = requested.SelectedProtectionSolutionSetting ?? existing.SelectedProtectionSolutionSetting; + string resourceId = requested.RecoveryResourceUniqueId; + + if (effectiveSolutionType is null || effectiveSolutionType == ResourceProtectionSolutionType.None || effectiveSolutionSetting is null) { - ((IJsonModel)operation.Value).Write(writer, ModelReaderWriterOptions.Json); + throw new ArgumentException($"Recovery resource '{resourceId}' requires selectedProtectionSolutionType and selectedProtectionSolutionSetting when it is first included."); } - using JsonDocument document = JsonDocument.Parse(stream.ToArray()); - return document.RootElement.Clone(); + if (effectiveSolutionType == ResourceProtectionSolutionType.AzureNative) + { + throw new ArgumentException($"Recovery resource '{resourceId}' cannot use AzureNative when included. Use AzureSiteRecovery for a virtual machine or CustomRunbook for another resource type."); + } + + if (effectiveSolutionType == ResourceProtectionSolutionType.CustomRunbook) + { + if (effectiveSolutionSetting is not ResourceCustomProtectionSetting customRunbookSetting) + { + throw new ArgumentException($"Recovery resource '{resourceId}' requires a CustomRunbook selectedProtectionSolutionSetting that matches selectedProtectionSolutionType."); + } + + if (customRunbookSetting.FailoverActionResourceId is null || customRunbookSetting.ReprotectActionResourceId is null) + { + throw new ArgumentException($"Recovery resource '{resourceId}' requires failoverAction.resourceId and reprotectAction.resourceId in its CustomRunbook selectedProtectionSolutionSetting."); + } + + ValidateResourceType(customRunbookSetting.FailoverActionResourceId, "Microsoft.Automation/automationAccounts/runbooks", resourceId, "failoverAction.resourceId"); + ValidateResourceType(customRunbookSetting.FailoverCommitActionResourceId, "Microsoft.Automation/automationAccounts/runbooks", resourceId, "failoverCommitAction.resourceId"); + ValidateResourceType(customRunbookSetting.TestFailoverActionResourceId, "Microsoft.Automation/automationAccounts/runbooks", resourceId, "testFailoverAction.resourceId"); + ValidateResourceType(customRunbookSetting.TestFailoverCleanupActionResourceId, "Microsoft.Automation/automationAccounts/runbooks", resourceId, "testFailoverCleanupAction.resourceId"); + ValidateResourceType(customRunbookSetting.ReprotectActionResourceId, "Microsoft.Automation/automationAccounts/runbooks", resourceId, "reprotectAction.resourceId"); + return; + } + + if (effectiveSolutionType != ResourceProtectionSolutionType.AzureSiteRecovery || effectiveSolutionSetting is not ResourceSiteRecoveryProtectionSetting siteRecoverySetting) + { + throw new ArgumentException($"Recovery resource '{resourceId}' has inconsistent selectedProtectionSolutionType and selectedProtectionSolutionSetting values."); + } + + if (!string.Equals(existing.ResourceId?.ResourceType.ToString(), "Microsoft.Compute/virtualMachines", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Recovery resource '{resourceId}' can use AzureSiteRecovery only for a Microsoft.Compute/virtualMachines resource. Use CustomRunbook for this resource type."); + } + + if (siteRecoverySetting.DiskReprotectInputDetails.Count == 0 || + siteRecoverySetting.DiskReprotectInputDetails.Any(detail => detail.DiskResourceId is null || detail.StagingStorageAccountResourceId is null)) + { + throw new ArgumentException($"Recovery resource '{resourceId}' requires at least one diskReprotectInputDetails entry with diskResourceId and stagingStorageAccountResourceId in its AzureSiteRecovery selectedProtectionSolutionSetting."); + } + + foreach (DiskReprotectInputDetails detail in siteRecoverySetting.DiskReprotectInputDetails) + { + ValidateResourceType(detail.DiskResourceId, "Microsoft.Compute/disks", resourceId, "diskReprotectInputDetails.diskResourceId"); + ValidateResourceType(detail.StagingStorageAccountResourceId, "Microsoft.Storage/storageAccounts", resourceId, "diskReprotectInputDetails.stagingStorageAccountResourceId"); + } + + if (siteRecoverySetting.TestFailoverParamsNetworkResourceId is null) + { + throw new ArgumentException($"Recovery resource '{resourceId}' requires testFailoverParams.networkResourceId in its AzureSiteRecovery selectedProtectionSolutionSetting."); + } + + ValidateResourceType(siteRecoverySetting.TestFailoverParamsNetworkResourceId, "Microsoft.Network/virtualNetworks", resourceId, "testFailoverParams.networkResourceId"); + } + + private static bool IsNullOrNone(ResourceProtectionSolutionType? solutionType) + => solutionType is null || solutionType == ResourceProtectionSolutionType.None || string.IsNullOrEmpty(solutionType.Value.ToString()); + + private static void ValidateResourceType(ResourceIdentifier? resourceIdentifier, string expectedResourceType, string recoveryResourceId, string propertyName) + { + if (resourceIdentifier is not null && + !string.Equals(resourceIdentifier.ResourceType.ToString(), expectedResourceType, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Recovery resource '{recoveryResourceId}' requires {propertyName} to identify a {expectedResourceType} resource."); + } } internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroupsSetting? existingRecoveryGroups, string? defaultGroupDescription) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs index 6023f55064..8ef8cf43ef 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Net; -using System.Text.Json; using Azure.Mcp.Tools.ResilienceManagement.Commands; using Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; using Azure.Mcp.Tools.ResilienceManagement.Models; @@ -239,7 +238,7 @@ public async Task ExecuteAsync_AcceptsDefaultGroupDescriptionBoundaryLengths(str } [Fact] - public async Task ExecuteAsync_RejectsRegionalPlanType() + public async Task ExecuteAsync_RejectsUnsupportedRegionalPlanTypeDuringBinding() { var response = await ExecuteCommandAsync( "--service-group", "sg1", @@ -250,7 +249,8 @@ public async Task ExecuteAsync_RejectsRegionalPlanType() "--default-group-description", "default"); Assert.Equal(HttpStatusCode.BadRequest, response.Status); - Assert.Contains("Only Zonal recovery plans are currently supported", response.Message); + Assert.Contains("Invalid --plan-type 'Regional'", response.Message); + Assert.Contains("Zonal", response.Message); await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), @@ -283,7 +283,7 @@ public async Task ExecuteAsync_ReturnsRecoveryPlanAndForwardsCompletePutOptions( var response = await ExecuteCommandAsync(ValidArgs); var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.RecoveryPlanCreateCommandResult); - Assert.Equal("plan1", result.RecoveryPlan.GetProperty("name").GetString()); + Assert.Equal("plan1", result.RecoveryPlan.Name); await Service.Received(1).CreateRecoveryPlanAsync( "sg1", "plan1", @@ -517,8 +517,16 @@ public async Task ExecuteAsync_HandlesServiceErrors() Assert.StartsWith("Test error", response.Message); } - private static JsonElement Element(string name) - => JsonDocument.Parse($"{{\"id\":\"id1\",\"name\":\"{name}\"}}").RootElement.Clone(); + private static RecoveryPlanInfo Element(string name) => new( + "id1", + name, + "Zonal", + "description", + null, + null, + new RecoveryPlanIdentityInfo("UserAssigned", [UserAssignedIdentityResourceId]), + new RecoveryPlanGroupInfo("12345678-9012-3456-7890-123456789012", 0, "default"), + []); private void ConfigureRequestFailure(HttpStatusCode status, string message) { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs index 79ed665c41..1053d941fa 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanDeleteCommandTests.cs @@ -49,6 +49,24 @@ public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSu Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); } + [Fact] + public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "invalid_plan"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("5 to 24 characters", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ASCII letters, numbers, or hyphens", response.Message, StringComparison.OrdinalIgnoreCase); + await Service.DidNotReceive().DeleteRecoveryPlanAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs index 4470d65b46..f17a048e7e 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using System.Net; -using System.Text.Json; using Azure.Mcp.Tools.ResilienceManagement.Commands; using Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Models; using Azure.Mcp.Tools.ResilienceManagement.Services; using Azure.ResourceManager.ResilienceManagement.Models; using Microsoft.Mcp.Core.Options; @@ -33,9 +33,12 @@ public void Constructor_InitializesCommandCorrectly() Assert.Equal("update-resources", command.Name); Assert.NotNull(command.Description); Assert.Contains("includes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); - Assert.Contains("excludes a recovery resource from a recovery plan", command.Description, StringComparison.OrdinalIgnoreCase); - Assert.Contains("removes a recovery resource from a recovery plan", command.Description, StringComparison.OrdinalIgnoreCase); - Assert.Contains("protection solution type and settings", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("excludes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("removes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CustomRunbook", command.Description, StringComparison.Ordinal); + Assert.Contains("AzureSiteRecovery", command.Description, StringComparison.Ordinal); + Assert.Contains("Validates the protection", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("solution type and settings", command.Description, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -49,6 +52,19 @@ public async Task ExecuteAsync_RequiresAnUpdateOrRemoval() Assert.Contains("at least one", response.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "invalid_plan", + "--resources-to-update", ResourcesToUpdateWithoutId); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("5 to 24 characters", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ASCII letters, numbers, or hyphens", response.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ExecuteAsync_ForwardsResourceUpdates() { @@ -65,7 +81,7 @@ public async Task ExecuteAsync_ForwardsResourceUpdates() null, null, Arg.Any()) - .Returns(Element("Succeeded")); + .Returns(UpdateResult()); var response = await ExecuteCommandAsync( "--service-group", "sg1", @@ -73,7 +89,7 @@ public async Task ExecuteAsync_ForwardsResourceUpdates() "--resources-to-update", ResourcesToUpdate); var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.RecoveryPlanUpdateResourcesCommandResult); - Assert.Equal("Succeeded", result.Result.GetProperty("status").GetString()); + Assert.Empty(result.Result.FailedResources); await Service.Received(1).UpdateRecoveryPlanResourcesAsync( "sg1", "plan1", @@ -96,7 +112,7 @@ public async Task ExecuteAsync_AllowsRemovalOnly() null, null, Arg.Any()) - .Returns(Element("Succeeded")); + .Returns(UpdateResult()); var response = await ExecuteCommandAsync( "--service-group", "sg1", @@ -131,7 +147,7 @@ public async Task ExecuteAsync_AllowsNullInclusionState() Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Element("Succeeded")); + .Returns(UpdateResult()); var response = await ExecuteCommandAsync( "--service-group", "sg1", @@ -153,7 +169,7 @@ public async Task ExecuteAsync_AllowsReadOnlyIdToBeOmitted() Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Element("Succeeded")); + .Returns(UpdateResult()); var response = await ExecuteCommandAsync( "--service-group", "sg1", @@ -241,9 +257,5 @@ public async Task ExecuteAsync_SanitizesProviderFailure() Assert.DoesNotContain("provider details", response.Message); } - private static JsonElement Element(string status) - { - using JsonDocument document = JsonDocument.Parse($$"""{"status":"{{status}}"}"""); - return document.RootElement.Clone(); - } + private static RecoveryPlanUpdateResourcesResult UpdateResult() => new([]); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index aacdb5d17a..511e9c23a5 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -29,7 +29,8 @@ public class ResilienceManagementCommandTests( private readonly ResilienceManagementTestCleanupFixture _cleanupFixture = cleanupFixture; // Prepend the base sanitizers (e.g. WWW-Authenticate) then add tool-specific ones. - // Sanitize x-ms-operation-identifier response header which contains the real tenant ID and object ID. + // Sanitize the required per-invocation operation-id request GUID for playback matching and the + // x-ms-operation-identifier response header, which contains the real tenant ID and object ID. public override List HeaderRegexSanitizers => [ .. base.HeaderRegexSanitizers, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index ab9e55aa56..ea01f9a4cb 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.ClientModel.Primitives; using Azure.Core; using Azure.Mcp.Tools.ResilienceManagement.Models; using Azure.Mcp.Tools.ResilienceManagement.Services; +using Azure.ResourceManager.ResilienceManagement; using Azure.ResourceManager.Models; using Azure.ResourceManager.ResilienceManagement.Models; using Xunit; @@ -70,6 +72,342 @@ public void ResolveRecoveryPlanDescription_ForCreate_RequiresDescription() Assert.Contains("--plan-description is required", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void CreateRecoveryPlanUpdateResourcesResult_MapsFailedResourceDetails() + { + RecoveryMembersData failedResource = ModelReaderWriter.Read(BinaryData.FromObjectAsJson(new + { + id = "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/12345678-9012-3456-7890-123456789012", + name = "12345678-9012-3456-7890-123456789012", + type = "Microsoft.AzureResilienceManagement/recoveryPlans/recoveryResources", + properties = new + { + recoveryResourceUniqueId = "12345678-9012-3456-7890-123456789012", + resourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm", + errorDetails = new + { + code = "InvalidConfiguration", + message = "The recovery resource configuration is invalid." + } + } + }))!; + + RecoveryPlanUpdateResourcesResult result = ResilienceManagementService.CreateRecoveryPlanUpdateResourcesResult([failedResource]); + + RecoveryPlanUpdateResourcesFailedResource failedResult = Assert.Single(result.FailedResources); + Assert.Equal(failedResource.Id.ToString(), failedResult.RecoveryResourceId); + Assert.Equal("12345678-9012-3456-7890-123456789012", failedResult.RecoveryResourceUniqueId); + Assert.Equal(failedResource.Properties.ResourceId.ToString(), failedResult.AzureResourceId); + Assert.Equal("InvalidConfiguration", failedResult.Error?.Code); + Assert.Equal("The recovery resource configuration is invalid.", failedResult.Error?.Message); + } + + [Fact] + public void CreateRecoveryPlanInfo_MapsSupportedPlanFields() + { + RecoveryPlanData recoveryPlan = ModelReaderWriter.Read(BinaryData.FromObjectAsJson(new + { + id = "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1", + name = "plan1", + type = "Microsoft.AzureResilienceManagement/recoveryPlans", + identity = new + { + type = "UserAssigned", + userAssignedIdentities = new Dictionary + { + [UserAssignedIdentityResourceId] = new { } + } + }, + properties = new + { + provisioningState = "Succeeded", + planType = "Zonal", + planState = "UnderEdit", + planDescription = "description", + recoveryGroupsSetting = new + { + defaultGroup = new + { + properties = new + { + groupUniqueId = "12345678-9012-3456-7890-123456789012", + orderId = 0, + description = "default" + } + }, + additionalGroups = new[] + { + new + { + properties = new + { + groupUniqueId = "12345678-9012-3456-7890-123456789013", + orderId = 1, + description = "additional" + } + } + } + } + } + }))!; + + RecoveryPlanInfo result = ResilienceManagementService.CreateRecoveryPlanInfo(recoveryPlan); + + Assert.Equal(recoveryPlan.Id.ToString(), result.Id); + Assert.Equal("plan1", result.Name); + Assert.Equal("Zonal", result.PlanType); + Assert.Equal("description", result.PlanDescription); + Assert.Equal("Succeeded", result.ProvisioningState); + Assert.Equal("UnderEdit", result.PlanState); + Assert.Equal("UserAssigned", result.Identity?.Type); + Assert.Equal([UserAssignedIdentityResourceId], result.Identity?.UserAssignedIdentityResourceIds); + Assert.Equal("12345678-9012-3456-7890-123456789012", result.DefaultGroup?.GroupUniqueId); + RecoveryPlanGroupInfo additionalGroup = Assert.Single(result.AdditionalGroups); + Assert.Equal("12345678-9012-3456-7890-123456789013", additionalGroup.GroupUniqueId); + Assert.Equal(1, additionalGroup.OrderId); + Assert.Equal("additional", additionalGroup.Description); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_FirstInclusionRequiresProtectionConfiguration() + { + RecoveryMembersData requested = CreateRecoveryResource(ResourceInclusionState.Included); + RecoveryMembersData existing = CreateRecoveryResource(); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("selectedProtectionSolutionType", exception.Message, StringComparison.Ordinal); + Assert.Contains("selectedProtectionSolutionSetting", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_ReinclusionRequiresProtectionConfigurationClearedByExclusion() + { + RecoveryMembersData requested = CreateRecoveryResource(ResourceInclusionState.Included); + RecoveryMembersData existing = CreateRecoveryResource(ResourceInclusionState.Excluded); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("selectedProtectionSolutionType", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_NonePreservesExistingProtectionConfiguration() + { + RecoveryMembersData requested = CreateRecoveryResource(solutionType: ResourceProtectionSolutionType.None); + RecoveryMembersData existing = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.CustomRunbook, + CreateCustomRunbookSetting()); + + ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_CustomRunbookRequiresMandatoryActions() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.CustomRunbook, + new ResourceCustomProtectionSetting()); + RecoveryMembersData existing = CreateRecoveryResource(); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("failoverAction.resourceId", exception.Message, StringComparison.Ordinal); + Assert.Contains("reprotectAction.resourceId", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_DoesNotRequireRecoveryGroupOrIdentity() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.CustomRunbook, + CreateCustomRunbookSetting()); + RecoveryMembersData existing = CreateRecoveryResource(); + + ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_ExcludedResourceDoesNotRequireProtectionConfiguration() + { + RecoveryMembersData requested = CreateRecoveryResource(ResourceInclusionState.Excluded); + RecoveryMembersData existing = CreateRecoveryResource(); + + ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_DelegatesExcludedResourcePropertyValidationToService() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Excluded, + ResourceProtectionSolutionType.CustomRunbook, + CreateCustomRunbookSetting()); + requested.Properties!.RecoveryGroupId = "7f35c9f5-bec2-455d-8161-c904b2532e5d"; + requested.Properties.AssociatedIdentity = new ResilienceManagementAssociatedIdentity(ManagedServiceIdentityType.UserAssigned) + { + UserAssignedIdentity = new ResourceIdentifier(UserAssignedIdentityResourceId) + }; + RecoveryMembersData existing = CreateRecoveryResource(ResourceInclusionState.Excluded); + + ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_CustomRunbookRequiresRunbookResourceIds() + { + ResourceCustomProtectionSetting setting = CreateCustomRunbookSetting(); + setting.FailoverActionResourceId = new ResourceIdentifier( + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account"); + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.CustomRunbook, + setting); + RecoveryMembersData existing = CreateRecoveryResource(); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("Microsoft.Automation/automationAccounts/runbooks", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_RejectsAzureNativeForIncludedResource() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.AzureNative, + new ResourceNativeProtectionSolutionSetting()); + RecoveryMembersData existing = CreateRecoveryResource(); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("cannot use AzureNative", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_RejectsMismatchedProtectionSetting() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.CustomRunbook, + new ResourceSiteRecoveryProtectionSetting()); + RecoveryMembersData existing = CreateRecoveryResource(); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("matches selectedProtectionSolutionType", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_RejectsAzureSiteRecoveryForNonVirtualMachine() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.AzureSiteRecovery, + CreateSiteRecoverySetting()); + RecoveryMembersData existing = CreateRecoveryResource( + azureResourceId: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account"); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("only for a Microsoft.Compute/virtualMachines resource", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_AzureSiteRecoveryRequiresDiskReprotectDetails() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.AzureSiteRecovery, + new ResourceSiteRecoveryProtectionSetting()); + RecoveryMembersData existing = CreateRecoveryResource( + azureResourceId: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("diskReprotectInputDetails", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_AzureSiteRecoveryRequiresTypedDiskAndStorageIds() + { + ResourceSiteRecoveryProtectionSetting setting = CreateSiteRecoverySetting(); + setting.DiskReprotectInputDetails[0].DiskResourceId = new ResourceIdentifier( + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account"); + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.AzureSiteRecovery, + setting); + RecoveryMembersData existing = CreateRecoveryResource( + azureResourceId: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("Microsoft.Compute/disks", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_AzureSiteRecoveryRequiresVirtualNetworkId() + { + ResourceSiteRecoveryProtectionSetting setting = CreateSiteRecoverySetting(); + setting.TestFailoverParamsNetworkResourceId = null; + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.AzureSiteRecovery, + setting); + RecoveryMembersData existing = CreateRecoveryResource( + azureResourceId: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("testFailoverParams.networkResourceId", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_AzureSiteRecoveryRequiresTypedVirtualNetworkId() + { + ResourceSiteRecoveryProtectionSetting setting = CreateSiteRecoverySetting(); + setting.TestFailoverParamsNetworkResourceId = new ResourceIdentifier( + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account"); + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.AzureSiteRecovery, + setting); + RecoveryMembersData existing = CreateRecoveryResource( + azureResourceId: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"); + + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("Microsoft.Network/virtualNetworks", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_AllowsValidAzureSiteRecoveryConfiguration() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.AzureSiteRecovery, + CreateSiteRecoverySetting()); + RecoveryMembersData existing = CreateRecoveryResource( + azureResourceId: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"); + + ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing); + } + [Fact] public void CreateRecoveryPlanIdentity_UsesUserAssignedIdentity() { @@ -142,4 +480,48 @@ private static RecoveryGroup CreateGroup(string groupId, int sequenceNumber, str { Properties = new RecoveryGroupProperties(groupId, sequenceNumber, description) }; + + private static RecoveryMembersData CreateRecoveryResource( + ResourceInclusionState? inclusionState = null, + ResourceProtectionSolutionType? solutionType = null, + ResourceBaseProtectionSolutionSetting? solutionSetting = null, + string? azureResourceId = null) + { + RecoveryMembersData resource = azureResourceId is null + ? new RecoveryMembersData() + : ModelReaderWriter.Read(BinaryData.FromObjectAsJson(new + { + properties = new + { + recoveryResourceUniqueId = "12345678-9012-3456-7890-123456789012", + resourceId = azureResourceId + } + }))!; + resource.Properties ??= new RecoveryResourceProperties("12345678-9012-3456-7890-123456789012"); + resource.Properties.InclusionState = inclusionState; + resource.Properties.SelectedProtectionSolutionType = solutionType; + resource.Properties.SelectedProtectionSolutionSetting = solutionSetting; + return resource; + } + + private static ResourceCustomProtectionSetting CreateCustomRunbookSetting() + => new() + { + FailoverActionResourceId = new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Automation/automationAccounts/account/runbooks/failover"), + ReprotectActionResourceId = new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Automation/automationAccounts/account/runbooks/reprotect") + }; + + private static ResourceSiteRecoveryProtectionSetting CreateSiteRecoverySetting() + { + var setting = new ResourceSiteRecoveryProtectionSetting + { + TestFailoverParamsNetworkResourceId = new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/network") + }; + setting.DiskReprotectInputDetails.Add(new DiskReprotectInputDetails + { + DiskResourceId = new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/disks/disk"), + StagingStorageAccountResourceId = new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/staging") + }); + return setting; + } } From 9a6061da517946cd3539637205bb6bdf61a94e8c Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Mon, 17 Aug 2026 00:11:04 +0530 Subject: [PATCH 09/38] Fix spelling validation for recovery plan commands --- .vscode/cspell.json | 1 + servers/Azure.Mcp.Server/cspell.yaml | 3 +++ tools/Azure.Mcp.Tools.ResilienceManagement/cspell.yaml | 3 +++ 3 files changed, 7 insertions(+) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index aece562a62..417add35e9 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -453,6 +453,7 @@ "recoverypoint", "registeredserver", "reindexing", + "resiliencemanagement", "resourcegroup", "resourcehealth", "roups", diff --git a/servers/Azure.Mcp.Server/cspell.yaml b/servers/Azure.Mcp.Server/cspell.yaml index cae715b81c..b98f2f6b1e 100644 --- a/servers/Azure.Mcp.Server/cspell.yaml +++ b/servers/Azure.Mcp.Server/cspell.yaml @@ -68,6 +68,8 @@ words: - precheck - prereq - registeredserver + - reprotect + - reprotection - scheduledtasks - serviceaccount - servicefabric @@ -80,6 +82,7 @@ words: - syncgroup - targetdir - tcount + - testcontainer - toolsets - toplist - webjobs diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/cspell.yaml b/tools/Azure.Mcp.Tools.ResilienceManagement/cspell.yaml index 6020e484b3..96cf170f72 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/cspell.yaml +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/cspell.yaml @@ -12,6 +12,9 @@ words: - goaltemplatename - recoveryjobname - recoveryplanname + - reinclusion + - reprotect + - reprotection - resiliencemanagement - servicegroupname - usageplanname From 63830ef67af4315d9974579e7e1ac6807a5ce443 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 18 Aug 2026 01:02:36 +0530 Subject: [PATCH 10/38] up --- .../Azure.Mcp.Server/docs/azmcp-commands.md | 2 +- .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 2 +- .../Plans/RecoveryPlanCreateCommand.cs | 11 +++-- .../Plans/RecoveryPlanCreateOption.cs | 4 +- .../RecoveryPlanUpdateResourcesOption.cs | 2 +- .../Services/ResilienceManagementService.cs | 14 ++++++- .../ResilienceManagementServiceTests.cs | 41 +++++++++++++++++-- 7 files changed, 61 insertions(+), 15 deletions(-) diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index ab2568e13c..902f20b38d 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3725,7 +3725,7 @@ azmcp resilience recovery plan get --subscription \ --service-group \ [--name ] -# Create or fully update a Zonal resilience recovery plan. An identity is required, and updates can use system-assigned, user-assigned, or both identity types. The plan description is required on create and preserved when omitted on update. +# Create or fully update a Zonal resilience recovery plan. An identity is required, and identity types can switch on update, but an existing user-assigned identity cannot be replaced with a different user-assigned identity. The plan description is required on create and preserved when omitted on update. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan create --service-group \ --recovery-plan \ diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 2b9c1d69cd..2bb3eb6802 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -912,7 +912,7 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_recovery_plan_create | Set up a Zonal recovery plan named in service group . Use a system-assigned managed identity, description , and default recovery group description | none | | resilience_recovery_plan_create | Create Zonal recovery plan in service group and attach user-assigned managed identity . Use for the plan description and for the default recovery group | none | | resilience_recovery_plan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | -| resilience_recovery_plan_create | Update recovery plan in service group to use a user-assigned managed identity | clarification-required | +| resilience_recovery_plan_create | Change a system-assigned recovery plan in service group to use a user-assigned managed identity | clarification-required | | resilience_recovery_plan_create | Update recovery plan in service group to use both its system-assigned identity and user-assigned managed identity . Preserve its existing plan settings | none | | resilience_recovery_plan_delete | Delete the entire recovery plan from service group | none | | resilience_recovery_plan_delete | Recovery plan is no longer needed. Delete it from resilience service group | none | diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index 73b056b0f3..c6b686a6f1 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -17,12 +17,11 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Title = "Create or Update Resilience Recovery Plan", Description = """ Create or fully update a Zonal resilience recovery plan in my service group with a required system-assigned, - user-assigned, or combined managed identity. Updates can switch between identity types and preserve the default recovery - group ID, additional recovery groups, and omitted plan or default group descriptions. The plan description is required - when creating a plan. A user-assigned identity update - must include the full identity resource ID. If it is not provided, ask the user for it instead of assuming the - recovery plan's existing user-assigned identity. Directly replacing one user-assigned identity with another is - not currently supported. + user-assigned, or combined managed identity. Updates can switch between identity types, but cannot replace an existing + user-assigned identity with a different user-assigned identity. Updates preserve the default recovery group ID, + additional recovery groups, and omitted plan or default group descriptions. The plan description is required when + creating a plan. A user-assigned identity update must include the existing identity's full resource ID. If it is not + provided, ask the user for it instead of assuming the recovery plan's existing user-assigned identity. """, Destructive = true, Idempotent = true, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs index a7586712a9..6732bfca68 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs @@ -21,10 +21,10 @@ public sealed class RecoveryPlanCreateOptions [Option(Description = "The recovery plan description, from 5 to 50 characters. Required when creating a plan; on update, the existing description is preserved when omitted.")] public string? PlanDescription { get; set; } - [Option(Description = "The managed identity type for the recovery plan. Supported values: SystemAssigned, UserAssigned, and SystemAndUserAssigned. Specify this on every create or update; updates can switch identity types.")] + [Option(Description = "The managed identity type for the recovery plan. Supported values: SystemAssigned, UserAssigned, and SystemAndUserAssigned. Specify this on every create or update; updates can switch identity types, but cannot replace an existing user-assigned identity with a different user-assigned identity.")] public required RecoveryPlanIdentityKind IdentityType { get; set; } - [Option(Description = "The full resource ID of the user-assigned managed identity. Required when --identity-type is UserAssigned or SystemAndUserAssigned and not allowed when it is SystemAssigned. Direct replacement of an existing user-assigned identity is not supported.")] + [Option(Description = "The full resource ID of the user-assigned managed identity. Required when --identity-type is UserAssigned or SystemAndUserAssigned and not allowed when it is SystemAssigned. On update, specify the existing user-assigned identity because changing it to a different user-assigned identity is not supported.")] public string? UserAssignedIdentity { get; set; } [Option(Description = "The default recovery group description, from 5 to 50 characters. On update, the existing description is preserved when omitted.")] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs index 4f4b12bc8e..60980275cb 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanUpdateResourcesOption.cs @@ -19,7 +19,7 @@ public sealed class RecoveryPlanUpdateResourcesOptions "Each item must contain properties.recoveryResourceUniqueId. " + "First inclusion and re-inclusion require matching selectedProtectionSolutionType and selectedProtectionSolutionSetting. " + "CustomRunbook requires failoverAction.resourceId and reprotectAction.resourceId values that identify Microsoft.Automation/automationAccounts/runbooks resources. " + - "AzureSiteRecovery is supported for Microsoft.Compute/virtualMachines resources and requires Microsoft.Compute/disks and Microsoft.Storage/storageAccounts IDs in diskReprotectInputDetails; " + + "AzureSiteRecovery requires a Microsoft.Compute/virtualMachines resource with healthy Azure Site Recovery protection and Microsoft.Compute/disks and Microsoft.Storage/storageAccounts IDs in diskReprotectInputDetails; " + "AzureSiteRecovery also requires testFailoverParams.networkResourceId identifying a Microsoft.Network/virtualNetworks resource. " + "The service preserves existing settings on sparse updates and permits only inclusionState changes while the resource is excluded. " + "recoveryGroupId and associatedIdentity are optional.")] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 4b5d14d956..845ce823fc 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -615,6 +615,13 @@ internal static void ValidateRecoveryResourceUpdate(RecoveryMembersData requeste } ValidateResourceType(siteRecoverySetting.TestFailoverParamsNetworkResourceId, "Microsoft.Network/virtualNetworks", resourceId, "testFailoverParams.networkResourceId"); + + if (!existing.ResourceProtectionSolutions.Any(solution => + solution.ProtectionSolutionType == ResourceProtectionSolutionType.AzureSiteRecovery && + solution.ProtectionStatus == ResourceProtectionStatus.Protected)) + { + throw new ArgumentException($"Recovery resource '{resourceId}' cannot use AzureSiteRecovery because healthy Azure Site Recovery protection was not detected. Enable Azure Site Recovery protection, wait until its status is Protected, and refresh the recovery plan."); + } } private static bool IsNullOrNone(ResourceProtectionSolutionType? solutionType) @@ -655,6 +662,11 @@ internal static ManagedServiceIdentity CreateRecoveryPlanIdentity(RecoveryPlanId } ResourceIdentifier identityResourceId = ParseUserAssignedIdentityResourceId(userAssignedIdentity!); + if (existingIdentity?.UserAssignedIdentities.Keys.Any(existingIdentityResourceId => existingIdentityResourceId != identityResourceId) == true) + { + throw new ArgumentException("Changing the user-assigned managed identity of an existing recovery plan is not supported.", nameof(userAssignedIdentity)); + } + ManagedServiceIdentityType managedServiceIdentityType = identityType == RecoveryPlanIdentityKind.SystemAndUserAssigned ? ManagedServiceIdentityType.SystemAssignedUserAssigned : ManagedServiceIdentityType.UserAssigned; @@ -663,7 +675,7 @@ internal static ManagedServiceIdentity CreateRecoveryPlanIdentity(RecoveryPlanId { if (existingIdentityResourceId != identityResourceId) { - throw new ArgumentException("Replacing an existing user-assigned managed identity with a different identity is not currently supported.", nameof(userAssignedIdentity)); + identity.UserAssignedIdentities.Add(existingIdentityResourceId, null!); } } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index ea01f9a4cb..b43ee92281 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -397,6 +397,18 @@ public void ValidateRecoveryResourceUpdate_AzureSiteRecoveryRequiresTypedVirtual [Fact] public void ValidateRecoveryResourceUpdate_AllowsValidAzureSiteRecoveryConfiguration() + { + RecoveryMembersData requested = CreateRecoveryResource( + ResourceInclusionState.Included, + ResourceProtectionSolutionType.AzureSiteRecovery, + CreateSiteRecoverySetting()); + RecoveryMembersData existing = CreateProtectedSiteRecoveryResource(); + + ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing); + } + + [Fact] + public void ValidateRecoveryResourceUpdate_RejectsAzureSiteRecoveryWhenProtectionIsNotDetected() { RecoveryMembersData requested = CreateRecoveryResource( ResourceInclusionState.Included, @@ -405,7 +417,10 @@ public void ValidateRecoveryResourceUpdate_AllowsValidAzureSiteRecoveryConfigura RecoveryMembersData existing = CreateRecoveryResource( azureResourceId: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"); - ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing); + ArgumentException exception = Assert.Throws( + () => ResilienceManagementService.ValidateRecoveryResourceUpdate(requested, existing)); + + Assert.Contains("healthy Azure Site Recovery protection was not detected", exception.Message, StringComparison.Ordinal); } [Fact] @@ -431,7 +446,7 @@ public void CreateRecoveryPlanIdentity_AllowsExistingUserAssignedIdentity() } [Fact] - public void CreateRecoveryPlanIdentity_RejectsReplacingExistingUserAssignedIdentity() + public void CreateRecoveryPlanIdentity_RejectsChangingExistingUserAssignedIdentity() { var existingIdentityResourceId = new ResourceIdentifier("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/oldIdentity"); var existingIdentity = new ManagedServiceIdentity(ManagedServiceIdentityType.UserAssigned); @@ -440,7 +455,7 @@ public void CreateRecoveryPlanIdentity_RejectsReplacingExistingUserAssignedIdent ArgumentException exception = Assert.Throws( () => ResilienceManagementService.CreateRecoveryPlanIdentity(RecoveryPlanIdentityKind.UserAssigned, UserAssignedIdentityResourceId, existingIdentity)); - Assert.Contains("not currently supported", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("not supported", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -504,6 +519,26 @@ private static RecoveryMembersData CreateRecoveryResource( return resource; } + private static RecoveryMembersData CreateProtectedSiteRecoveryResource() + => ModelReaderWriter.Read(BinaryData.FromObjectAsJson(new + { + properties = new + { + recoveryResourceUniqueId = "12345678-9012-3456-7890-123456789012", + resourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm", + protectionStatus = "Protected", + resourceProtectionSolutions = new[] + { + new + { + protectionSolutionType = "AzureSiteRecovery", + protectionStatus = "Protected", + resourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm" + } + } + } + }))!; + private static ResourceCustomProtectionSetting CreateCustomRunbookSetting() => new() { From 60840082d289069b94faf13fefb4d2465fcc39da Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 18 Aug 2026 20:25:29 +0530 Subject: [PATCH 11/38] up --- .../src/Commands/Recovery/Plans/RecoveryPlanValidation.cs | 2 +- .../src/Models/RecoveryPlanGroupInfo.cs | 2 +- .../src/Models/RecoveryPlanIdentityInfo.cs | 2 +- .../src/Models/RecoveryPlanInfo.cs | 2 +- .../src/Models/RecoveryPlanUpdateResourcesError.cs | 2 +- .../src/Models/RecoveryPlanUpdateResourcesFailedResource.cs | 2 +- .../src/Models/RecoveryPlanUpdateResourcesResult.cs | 2 +- .../Services/ResilienceManagementServiceTests.cs | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidation.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidation.cs index b62a38e956..c48e847552 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidation.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanValidation.cs @@ -17,4 +17,4 @@ public static void ValidateName(string recoveryPlan, ValidationResult validation private static bool IsValidNameCharacter(char character) => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'; -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInfo.cs index 4c913649ff..435b15705d 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInfo.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInfo.cs @@ -8,4 +8,4 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models; public sealed record RecoveryPlanGroupInfo( [property: JsonPropertyName("groupUniqueId")] string GroupUniqueId, [property: JsonPropertyName("orderId")] int OrderId, - [property: JsonPropertyName("description")] string Description); \ No newline at end of file + [property: JsonPropertyName("description")] string Description); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityInfo.cs index 855e981b50..37b2c452da 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityInfo.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanIdentityInfo.cs @@ -7,4 +7,4 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models; public sealed record RecoveryPlanIdentityInfo( [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("userAssignedIdentityResourceIds")] IReadOnlyList UserAssignedIdentityResourceIds); \ No newline at end of file + [property: JsonPropertyName("userAssignedIdentityResourceIds")] IReadOnlyList UserAssignedIdentityResourceIds); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanInfo.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanInfo.cs index 6d50657654..96c3916706 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanInfo.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanInfo.cs @@ -14,4 +14,4 @@ public sealed record RecoveryPlanInfo( [property: JsonPropertyName("planState")] string? PlanState, [property: JsonPropertyName("identity")] RecoveryPlanIdentityInfo? Identity, [property: JsonPropertyName("defaultGroup")] RecoveryPlanGroupInfo? DefaultGroup, - [property: JsonPropertyName("additionalGroups")] IReadOnlyList AdditionalGroups); \ No newline at end of file + [property: JsonPropertyName("additionalGroups")] IReadOnlyList AdditionalGroups); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesError.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesError.cs index 248c6fee56..22108ae11b 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesError.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesError.cs @@ -7,4 +7,4 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models; public sealed record RecoveryPlanUpdateResourcesError( [property: JsonPropertyName("code")] string? Code, - [property: JsonPropertyName("message")] string? Message); \ No newline at end of file + [property: JsonPropertyName("message")] string? Message); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesFailedResource.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesFailedResource.cs index a5f62935d6..664d510956 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesFailedResource.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesFailedResource.cs @@ -9,4 +9,4 @@ public sealed record RecoveryPlanUpdateResourcesFailedResource( [property: JsonPropertyName("recoveryResourceId")] string RecoveryResourceId, [property: JsonPropertyName("recoveryResourceUniqueId")] string RecoveryResourceUniqueId, [property: JsonPropertyName("azureResourceId")] string? AzureResourceId, - [property: JsonPropertyName("error")] RecoveryPlanUpdateResourcesError? Error); \ No newline at end of file + [property: JsonPropertyName("error")] RecoveryPlanUpdateResourcesError? Error); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesResult.cs index 4a29052a41..0ba353cd3c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesResult.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanUpdateResourcesResult.cs @@ -6,4 +6,4 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models; public sealed record RecoveryPlanUpdateResourcesResult( - [property: JsonPropertyName("failedResources")] IReadOnlyList FailedResources); \ No newline at end of file + [property: JsonPropertyName("failedResources")] IReadOnlyList FailedResources); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index b43ee92281..44a7ba4149 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -5,8 +5,8 @@ using Azure.Core; using Azure.Mcp.Tools.ResilienceManagement.Models; using Azure.Mcp.Tools.ResilienceManagement.Services; -using Azure.ResourceManager.ResilienceManagement; using Azure.ResourceManager.Models; +using Azure.ResourceManager.ResilienceManagement; using Azure.ResourceManager.ResilienceManagement.Models; using Xunit; From 37abc52795f2c45d4f8da84ec553f23ae0a9df47 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 18 Aug 2026 22:30:00 +0530 Subject: [PATCH 12/38] up --- servers/Azure.Mcp.Server/docs/azmcp-commands.md | 2 +- servers/Azure.Mcp.Server/docs/e2eTestPrompts.md | 1 + .../Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs | 6 ++++-- .../src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index f672f359ec..748bcaf41d 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3725,7 +3725,7 @@ azmcp resilience recovery plan get --subscription \ --service-group \ [--name ] -# Create or fully update a Zonal resilience recovery plan. An identity is required, and identity types can switch on update, but an existing user-assigned identity cannot be replaced with a different user-assigned identity. The plan description is required on create and preserved when omitted on update. +# Create or fully update a Zonal resilience recovery plan. Ask the customer to select an identity type; do not assume SystemAssigned or another default. Identity types can switch on update, but an existing user-assigned identity cannot be replaced with a different user-assigned identity. The plan description must be 5 to 50 characters and is required on create; it is preserved when omitted on update. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan create --service-group \ --recovery-plan \ diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 2511ba31ab..9c498c6135 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -917,6 +917,7 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_recovery_job_get | Get the details of recovery job for recovery plan in service group | none | | resilience_recovery_job_resource_get | List all resources (targets) of recovery job for recovery plan in service group | none | | resilience_recovery_job_resource_get | Get the recovery job resource for recovery job of recovery plan in service group | none | +| resilience_recovery_plan_create | Create a Zonal recovery plan named in service group | clarification-required | | resilience_recovery_plan_create | Set up a Zonal recovery plan named in service group . Use a system-assigned managed identity, description , and default recovery group description | none | | resilience_recovery_plan_create | Create Zonal recovery plan in service group and attach user-assigned managed identity . Use for the plan description and for the default recovery group | none | | resilience_recovery_plan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index c6b686a6f1..046ca7c720 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -20,8 +20,10 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; user-assigned, or combined managed identity. Updates can switch between identity types, but cannot replace an existing user-assigned identity with a different user-assigned identity. Updates preserve the default recovery group ID, additional recovery groups, and omitted plan or default group descriptions. The plan description is required when - creating a plan. A user-assigned identity update must include the existing identity's full resource ID. If it is not - provided, ask the user for it instead of assuming the recovery plan's existing user-assigned identity. + creating a plan and must be 5 to 50 characters. Do not assume SystemAssigned or any other identity type. If the user + does not specify an identity type, ask them to choose SystemAssigned, UserAssigned, or SystemAndUserAssigned. A + user-assigned identity update must include the existing identity's full resource ID. If it is not provided, ask the + user for it instead of assuming the recovery plan's existing user-assigned identity. """, Destructive = true, Idempotent = true, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs index 6732bfca68..9dd5a07b94 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs @@ -21,7 +21,7 @@ public sealed class RecoveryPlanCreateOptions [Option(Description = "The recovery plan description, from 5 to 50 characters. Required when creating a plan; on update, the existing description is preserved when omitted.")] public string? PlanDescription { get; set; } - [Option(Description = "The managed identity type for the recovery plan. Supported values: SystemAssigned, UserAssigned, and SystemAndUserAssigned. Specify this on every create or update; updates can switch identity types, but cannot replace an existing user-assigned identity with a different user-assigned identity.")] + [Option(Description = "The customer-selected managed identity type for the recovery plan. Supported values: SystemAssigned, UserAssigned, and SystemAndUserAssigned. Do not assume a default; ask the customer when they have not specified an identity type. Specify this on every create or update; updates can switch identity types, but cannot replace an existing user-assigned identity with a different user-assigned identity.")] public required RecoveryPlanIdentityKind IdentityType { get; set; } [Option(Description = "The full resource ID of the user-assigned managed identity. Required when --identity-type is UserAssigned or SystemAndUserAssigned and not allowed when it is SystemAssigned. On update, specify the existing user-assigned identity because changing it to a different user-assigned identity is not supported.")] From b12b171cc59c7367d2a25560c957ed6b212263f7 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 18 Aug 2026 23:26:56 +0530 Subject: [PATCH 13/38] up --- .../Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index b2add2de54..40b0950007 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -3,4 +3,4 @@ "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_cb0c443485" -} +} \ No newline at end of file From b4f63a433fb18178a7b4572d84ae4fb62b0d0863 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 18 Aug 2026 23:47:06 +0530 Subject: [PATCH 14/38] up --- .../Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs | 5 +++-- .../Plans/RecoveryPlanUpdateResourcesCommandTests.cs | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs index a05539be3b..ba44dc0f3b 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -22,8 +22,9 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Description = """ Includes a recovery resource in a recovery plan in an Azure service group, excludes a recovery resource from a recovery plan in an Azure service group, or removes a recovery resource from a recovery plan in an Azure service group. For - CustomRunbook inclusion, configure failover and reprotect runbooks. For AzureSiteRecovery inclusion, configure disk - reprotection, staging storage, and a test failover virtual network. Removing an individual recovery resource updates the + CustomRunbook inclusion, configure failover and reprotect runbooks. AzureSiteRecovery inclusion is supported only for + Microsoft.Compute/virtualMachines resources and requires disk reprotection, staging storage, and a test failover virtual + network. Removing an individual recovery resource updates the plan's resource membership while retaining the recovery plan and its other recovery resources. Validates the protection solution type and settings before first inclusion and preserves existing settings on sparse updates. """, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs index f17a048e7e..c026511b9b 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs @@ -37,6 +37,8 @@ public void Constructor_InitializesCommandCorrectly() Assert.Contains("removes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); Assert.Contains("CustomRunbook", command.Description, StringComparison.Ordinal); Assert.Contains("AzureSiteRecovery", command.Description, StringComparison.Ordinal); + Assert.Contains("supported only", command.Description, StringComparison.Ordinal); + Assert.Contains("Microsoft.Compute/virtualMachines", command.Description, StringComparison.Ordinal); Assert.Contains("Validates the protection", command.Description, StringComparison.OrdinalIgnoreCase); Assert.Contains("solution type and settings", command.Description, StringComparison.OrdinalIgnoreCase); } From f6aa4b884dead63a37f8bfb8631437d91306d3cb Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 01:42:59 +0530 Subject: [PATCH 15/38] up --- .../ResilienceManagementCommandTests.cs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index 9e1dac06e9..3d88f72ef6 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -305,11 +305,7 @@ public async Task Should_create_update_and_delete_recovery_plan() var createdPlan = createResult.AssertProperty("recoveryPlan"); Assert.EndsWith($"/recoveryPlans/{recoveryPlan}", createdPlan.AssertProperty("id").GetString()); Assert.Equal("SystemAssigned", createdPlan.AssertProperty("identity").AssertProperty("type").GetString()); - var createdDefaultGroup = createdPlan - .AssertProperty("properties") - .AssertProperty("recoveryGroupsSetting") - .AssertProperty("defaultGroup") - .AssertProperty("properties"); + var createdDefaultGroup = createdPlan.AssertProperty("defaultGroup"); var defaultGroupId = createdDefaultGroup.AssertProperty("groupUniqueId").GetString(); Assert.False(string.IsNullOrEmpty(defaultGroupId)); Assert.Equal("Lifecycle default group", createdDefaultGroup.AssertProperty("description").GetString()); @@ -340,12 +336,8 @@ public async Task Should_create_update_and_delete_recovery_plan() var updatedPlan = updateResult.AssertProperty("recoveryPlan"); Assert.Equal( "Updated recovery plan lifecycle test.", - updatedPlan.AssertProperty("properties").AssertProperty("planDescription").GetString()); - var updatedDefaultGroup = updatedPlan - .AssertProperty("properties") - .AssertProperty("recoveryGroupsSetting") - .AssertProperty("defaultGroup") - .AssertProperty("properties"); + updatedPlan.AssertProperty("planDescription").GetString()); + var updatedDefaultGroup = updatedPlan.AssertProperty("defaultGroup"); Assert.Equal(defaultGroupId, updatedDefaultGroup.AssertProperty("groupUniqueId").GetString()); Assert.Equal("Lifecycle default group", updatedDefaultGroup.AssertProperty("description").GetString()); From 38ff39c95d66ee49b11aaebbe69c13a16ff63c83 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 02:18:53 +0530 Subject: [PATCH 16/38] up --- .../ResilienceManagementCommandTests.cs | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index 3d88f72ef6..652599d2b2 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -169,7 +169,20 @@ public async Task Should_get_drill_resource() { var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); - var drillResourceName = RegisterOrRetrieveDeploymentOutputVariable("drillResourceName", "DRILLRESOURCENAME"); + + var listResult = await CallToolAsync( + "resilience_drill_resource_get", + new() + { + { "service-group", serviceGroup }, + { "drill", drillName } + }); + + var drillResources = listResult.AssertProperty("drillResources"); + Assert.NotEqual(0, drillResources.GetArrayLength()); + var drillResourceName = RegisterOrRetrieveVariable( + "drillResourceName", + drillResources.EnumerateArray().First().AssertProperty("name").GetString()!); var result = await CallToolAsync( "resilience_drill_resource_get", @@ -457,7 +470,21 @@ public async Task Should_get_recovery_job() { var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); - var recoveryJob = RegisterOrRetrieveDeploymentOutputVariable("recoveryJobName", "RECOVERYJOBNAME"); + + var listResult = await CallToolAsync( + "resilience_recovery_job_get", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan } + }); + + var recoveryJobs = listResult.AssertProperty("recoveryJobs"); + Assert.NotEqual(0, recoveryJobs.GetArrayLength()); + var recoveryJob = RegisterOrRetrieveVariable( + "recoveryJobName", + recoveryJobs.EnumerateArray().First().AssertProperty("name").GetString()!); var result = await CallToolAsync( "resilience_recovery_job_get", @@ -478,7 +505,21 @@ public async Task Should_list_recovery_job_resources() { var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); - var recoveryJob = RegisterOrRetrieveDeploymentOutputVariable("recoveryJobName", "RECOVERYJOBNAME"); + + var listResult = await CallToolAsync( + "resilience_recovery_job_get", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan } + }); + + var recoveryJobs = listResult.AssertProperty("recoveryJobs"); + Assert.NotEqual(0, recoveryJobs.GetArrayLength()); + var recoveryJob = RegisterOrRetrieveVariable( + "recoveryJobName", + recoveryJobs.EnumerateArray().First().AssertProperty("name").GetString()!); var result = await CallToolAsync( "resilience_recovery_job_resource_get", From 68b82e3002933d7931706773d988c5615ecdc16b Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 13:59:31 +0530 Subject: [PATCH 17/38] up --- .../ResilienceManagementCommandTests.cs | 13 ++++--------- .../assets.json | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index 652599d2b2..3eb9437818 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -278,17 +278,12 @@ public async Task Should_update_recovery_plan() var plan = result.AssertProperty("recoveryPlan"); Assert.EndsWith($"/recoveryPlans/{recoveryPlan}", plan.AssertProperty("id").GetString()); Assert.Equal("SystemAssigned", plan.AssertProperty("identity").AssertProperty("type").GetString()); - var updatedRecoveryGroups = plan - .AssertProperty("properties") - .AssertProperty("recoveryGroupsSetting"); - var updatedDefaultGroupProperties = updatedRecoveryGroups - .AssertProperty("defaultGroup") - .AssertProperty("properties"); - Assert.Equal(defaultGroupId, updatedDefaultGroupProperties.AssertProperty("groupUniqueId").GetString()); - Assert.Equal(defaultGroupDescription, updatedDefaultGroupProperties.AssertProperty("description").GetString()); + var updatedDefaultGroup = plan.AssertProperty("defaultGroup"); + Assert.Equal(defaultGroupId, updatedDefaultGroup.AssertProperty("groupUniqueId").GetString()); + Assert.Equal(defaultGroupDescription, updatedDefaultGroup.AssertProperty("description").GetString()); Assert.Equal( existingAdditionalGroups, - updatedRecoveryGroups.AssertProperty("additionalGroups").EnumerateArray().Select(group => group.GetRawText())); + plan.AssertProperty("additionalGroups").EnumerateArray().Select(group => group.GetRawText())); } [Fact] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index 40b0950007..0e015b2f67 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", - "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_cb0c443485" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_1479f47486" } \ No newline at end of file From f7df6994a618d45fd324d1e7558342384c4a16ab Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 14:32:36 +0530 Subject: [PATCH 18/38] up --- .../Recovery/Plans/RecoveryPlanCreateCommand.cs | 14 ++++++-------- .../Plans/RecoveryPlanUpdateResourcesCommand.cs | 12 +++++------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index 046ca7c720..0fd0a872ff 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -16,14 +16,12 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Name = "create", Title = "Create or Update Resilience Recovery Plan", Description = """ - Create or fully update a Zonal resilience recovery plan in my service group with a required system-assigned, - user-assigned, or combined managed identity. Updates can switch between identity types, but cannot replace an existing - user-assigned identity with a different user-assigned identity. Updates preserve the default recovery group ID, - additional recovery groups, and omitted plan or default group descriptions. The plan description is required when - creating a plan and must be 5 to 50 characters. Do not assume SystemAssigned or any other identity type. If the user - does not specify an identity type, ask them to choose SystemAssigned, UserAssigned, or SystemAndUserAssigned. A - user-assigned identity update must include the existing identity's full resource ID. If it is not provided, ask the - user for it instead of assuming the recovery plan's existing user-assigned identity. + Creates a new Zonal resilience recovery plan in an Azure service group or fully updates an existing recovery plan. + Creation requires a plan description and a customer-selected SystemAssigned, UserAssigned, or SystemAndUserAssigned + managed identity. Do not assume an identity type; ask the user to choose one when omitted. Updates can switch identity + types, but cannot replace an existing user-assigned identity with a different one. Updates preserve the default recovery + group ID, additional recovery groups, and omitted plan or default group descriptions. Plan descriptions must be 5 to 50 + characters. A user-assigned identity update must include the existing identity's full resource ID; ask for it when omitted. """, Destructive = true, Idempotent = true, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs index ba44dc0f3b..f77f336259 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -20,13 +20,11 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Name = "update-resources", Title = "Update Resilience Recovery Plan Resources", Description = """ - Includes a recovery resource in a recovery plan in an Azure service group, excludes a recovery resource from a recovery - plan in an Azure service group, or removes a recovery resource from a recovery plan in an Azure service group. For - CustomRunbook inclusion, configure failover and reprotect runbooks. AzureSiteRecovery inclusion is supported only for - Microsoft.Compute/virtualMachines resources and requires disk reprotection, staging storage, and a test failover virtual - network. Removing an individual recovery resource updates the - plan's resource membership while retaining the recovery plan and its other recovery resources. Validates the protection - solution type and settings before first inclusion and preserves existing settings on sparse updates. + Updates recovery resources in a resilience recovery plan in an Azure service group: includes and configures a resource + with protection settings; keeps a resource in the plan but excludes it from recovery operations; or removes a resource + from membership while retaining the plan and all other resources. Supports CustomRunbook with failover and reprotect + runbooks. Supports AzureSiteRecovery only for virtual machines with disk reprotection, staging storage, and a test + failover virtual network. """, Destructive = true, Idempotent = true, From 4f9392d080d6bb965ddec2f2514afce7d9040391 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 16:03:01 +0530 Subject: [PATCH 19/38] up --- .../Plans/RecoveryPlanUpdateResourcesCommand.cs | 10 +++++----- .../Plans/RecoveryPlanUpdateResourcesCommandTests.cs | 12 +++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs index f77f336259..76e392622d 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -20,11 +20,11 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Name = "update-resources", Title = "Update Resilience Recovery Plan Resources", Description = """ - Updates recovery resources in a resilience recovery plan in an Azure service group: includes and configures a resource - with protection settings; keeps a resource in the plan but excludes it from recovery operations; or removes a resource - from membership while retaining the plan and all other resources. Supports CustomRunbook with failover and reprotect - runbooks. Supports AzureSiteRecovery only for virtual machines with disk reprotection, staging storage, and a test - failover virtual network. + Updates recovery resources in a resilience recovery plan: includes a recovery resource and configures protection + settings; excludes a recovery resource from recovery operations; or removes a recovery resource from membership while + retaining the plan and other resources. Validates the protection solution type and settings. Supports CustomRunbook + failover and reprotect runbooks. AzureSiteRecovery is supported only for Microsoft.Compute/virtualMachines with disk + reprotection, staging storage, and a test failover virtual network. """, Destructive = true, Idempotent = true, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs index c026511b9b..d8b672ec1b 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs @@ -32,15 +32,13 @@ public void Constructor_InitializesCommandCorrectly() var command = Command.GetCommand(); Assert.Equal("update-resources", command.Name); Assert.NotNull(command.Description); - Assert.Contains("includes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); - Assert.Contains("excludes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); - Assert.Contains("removes a recovery resource", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("includes and configures a resource", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("excludes it from recovery operations", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("removes a resource", command.Description, StringComparison.OrdinalIgnoreCase); Assert.Contains("CustomRunbook", command.Description, StringComparison.Ordinal); Assert.Contains("AzureSiteRecovery", command.Description, StringComparison.Ordinal); - Assert.Contains("supported only", command.Description, StringComparison.Ordinal); - Assert.Contains("Microsoft.Compute/virtualMachines", command.Description, StringComparison.Ordinal); - Assert.Contains("Validates the protection", command.Description, StringComparison.OrdinalIgnoreCase); - Assert.Contains("solution type and settings", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("only for virtual machines", command.Description, StringComparison.OrdinalIgnoreCase); + Assert.Contains("protection settings", command.Description, StringComparison.OrdinalIgnoreCase); } [Fact] From 485689780e38340d90b3cd81df8ad354b3217b4d Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 16:03:15 +0530 Subject: [PATCH 20/38] up --- .../Plans/RecoveryPlanUpdateResourcesCommand.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs index 76e392622d..f77f336259 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -20,11 +20,11 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Name = "update-resources", Title = "Update Resilience Recovery Plan Resources", Description = """ - Updates recovery resources in a resilience recovery plan: includes a recovery resource and configures protection - settings; excludes a recovery resource from recovery operations; or removes a recovery resource from membership while - retaining the plan and other resources. Validates the protection solution type and settings. Supports CustomRunbook - failover and reprotect runbooks. AzureSiteRecovery is supported only for Microsoft.Compute/virtualMachines with disk - reprotection, staging storage, and a test failover virtual network. + Updates recovery resources in a resilience recovery plan in an Azure service group: includes and configures a resource + with protection settings; keeps a resource in the plan but excludes it from recovery operations; or removes a resource + from membership while retaining the plan and all other resources. Supports CustomRunbook with failover and reprotect + runbooks. Supports AzureSiteRecovery only for virtual machines with disk reprotection, staging storage, and a test + failover virtual network. """, Destructive = true, Idempotent = true, From de376b14dca7e71ccd22792f586f3f10e0087c15 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 17:05:13 +0530 Subject: [PATCH 21/38] up --- .../ResilienceManagementCommandTests.cs | 6 +- .../ResilienceManagementLiveTestCollection.cs | 12 ---- .../ResilienceManagementTestCleanupFixture.cs | 70 ------------------- .../tests/remove-test-resources-pre.ps1 | 7 -- 4 files changed, 1 insertion(+), 94 deletions(-) delete mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs delete mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index 3eb9437818..dd5e154eba 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -17,16 +17,12 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests; /// Live / recorded integration tests for the Resilience Management toolset. /// Resources are provisioned by test-resources.bicep + test-resources-post.ps1. /// -[Collection(ResilienceManagementLiveTestCollection.Name)] public class ResilienceManagementCommandTests( ITestOutputHelper output, TestProxyFixture fixture, - LiveServerFixture liveServerFixture, - ResilienceManagementTestCleanupFixture cleanupFixture) + LiveServerFixture liveServerFixture) : RecordedCommandTestsBase(output, fixture, liveServerFixture) { - private readonly ResilienceManagementTestCleanupFixture _cleanupFixture = cleanupFixture; - // Prepend the base sanitizers (e.g. WWW-Authenticate) then add tool-specific ones. // Sanitize the required per-invocation operation-id request GUID for playback matching and the // x-ms-operation-identifier response header, which contains the real tenant ID and object ID. diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs deleted file mode 100644 index f551a476c3..0000000000 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementLiveTestCollection.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Xunit; - -namespace Azure.Mcp.Tools.ResilienceManagement.Tests; - -[CollectionDefinition(Name, DisableParallelization = true)] -public sealed class ResilienceManagementLiveTestCollection() : ICollectionFixture -{ - public const string Name = "ResilienceManagementLiveTests"; -} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs deleted file mode 100644 index 0d6584e854..0000000000 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementTestCleanupFixture.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics; -using Microsoft.Mcp.Tests.Client.Helpers; -using Microsoft.Mcp.Tests.Helpers; -using Xunit; - -namespace Azure.Mcp.Tools.ResilienceManagement.Tests; - -public sealed class ResilienceManagementTestCleanupFixture() : IAsyncLifetime -{ - public ValueTask InitializeAsync() => ValueTask.CompletedTask; - - public async ValueTask DisposeAsync() - { - if (!LiveTestSettings.TryLoadTestSettings(out var settings) || settings.TestMode == TestMode.Playback) - { - return; - } - - var cleanupScript = Path.Combine(settings.SettingsDirectory, "remove-test-resources-pre.ps1"); - if (!File.Exists(cleanupScript)) - { - Console.Error.WriteLine($"WARNING: Resilience Management cleanup script was not found at '{cleanupScript}'."); - return; - } - - try - { - var startInfo = new ProcessStartInfo("pwsh") - { - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - UseShellExecute = false - }; - startInfo.ArgumentList.Add("-NoLogo"); - startInfo.ArgumentList.Add("-NoProfile"); - startInfo.ArgumentList.Add("-NonInteractive"); - startInfo.ArgumentList.Add("-File"); - startInfo.ArgumentList.Add(cleanupScript); - startInfo.ArgumentList.Add("-ResourceGroupName"); - startInfo.ArgumentList.Add(settings.ResourceGroupName); - startInfo.ArgumentList.Add("-TestSettingsPath"); - startInfo.ArgumentList.Add(Path.Combine(settings.SettingsDirectory, LiveTestSettings.TestSettingsFileName)); - - using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start Resilience Management cleanup."); - var outputTask = process.StandardOutput.ReadToEndAsync(); - var errorTask = process.StandardError.ReadToEndAsync(); - await process.WaitForExitAsync(); - var output = await outputTask; - var error = await errorTask; - - if (!string.IsNullOrWhiteSpace(output)) - { - Console.WriteLine(output); - } - - if (process.ExitCode != 0) - { - Console.Error.WriteLine($"WARNING: Resilience Management cleanup exited with code {process.ExitCode}: {error}"); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"WARNING: Resilience Management cleanup failed: {ex.Message}"); - } - } -} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 index cbb158c5be..4018a48820 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 @@ -15,7 +15,6 @@ $testSettings = Get-Content $TestSettingsPath -Raw | ConvertFrom-Json $outputs = $testSettings.DeploymentOutputs $subscriptionId = $testSettings.SubscriptionId $tenantId = $testSettings.TenantId -$serviceGroupApiVersion = '2024-02-01-preview' $membershipApiVersion = '2023-09-01-preview' $resilienceApiVersion = '2026-04-01-preview' @@ -141,9 +140,3 @@ Remove-Resource -ResourceId "$resilienceBase/goalTemplates/$goalTemplateName" -A Remove-Resource -ResourceId "$usagePlanId/enrollments/$enrollmentName" -ApiVersion $resilienceApiVersion Remove-Resource -ResourceId "$usagePlanId/enrollments/$lifecycleEnrollmentName" -ApiVersion $resilienceApiVersion Remove-Resource -ResourceId "/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Relationships/serviceGroupMember/rhub-rg-member" -ApiVersion $membershipApiVersion - -foreach ($serviceGroupIdToDelete in @($lifecycleServiceGroupId, $serviceGroupId)) { - if ($serviceGroupIdToDelete -notmatch '/$') { - Remove-Resource -ResourceId $serviceGroupIdToDelete -ApiVersion $serviceGroupApiVersion - } -} \ No newline at end of file From 4ebaf4dfcdb69488df2d3679726fd436efcc7c63 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 17:09:36 +0530 Subject: [PATCH 22/38] up --- .../tests/remove-test-resources-pre.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 index 4018a48820..4c1d37a7b2 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/remove-test-resources-pre.ps1 @@ -111,6 +111,7 @@ $enrollmentName = Get-OutputValue -Name 'enrollmentName' $lifecycleEnrollmentName = Get-OutputValue -Name 'lifecycleEnrollmentName' $goalAssignmentName = Get-OutputValue -Name 'goalAssignmentName' $goalTemplateName = Get-OutputValue -Name 'goalTemplateName' +$drillName = Get-OutputValue -Name 'drillName' $requiredOutputs = @{ serviceGroupName = $serviceGroupName @@ -120,6 +121,7 @@ $requiredOutputs = @{ lifecycleEnrollmentName = $lifecycleEnrollmentName goalAssignmentName = $goalAssignmentName goalTemplateName = $goalTemplateName + drillName = $drillName } foreach ($requiredOutput in $requiredOutputs.GetEnumerator()) { @@ -133,6 +135,7 @@ $lifecycleServiceGroupId = "/providers/Microsoft.Management/serviceGroups/$lifec $resilienceBase = "$serviceGroupId/providers/Microsoft.AzureResilienceManagement" $usagePlanId = "/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.AzureResilienceManagement/usagePlans/$usagePlanName" +Remove-Resource -ResourceId "$resilienceBase/drills/$drillName" -ApiVersion $resilienceApiVersion Remove-RecoveryPlans -ServiceGroupId $serviceGroupId Remove-RecoveryPlans -ServiceGroupId $lifecycleServiceGroupId Remove-Resource -ResourceId "$resilienceBase/goalAssignments/$goalAssignmentName" -ApiVersion $resilienceApiVersion From f93ac9eae8be883ea1617a5590e42e0acfc8b500 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Wed, 19 Aug 2026 23:38:26 +0530 Subject: [PATCH 23/38] changes --- .../resilience-recovery-plan-commands.yaml | 2 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 5 + .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 2 + .../RecoveryPlanCheckReadinessCommand.cs | 75 ++++++ .../ResilienceManagementJsonContext.cs | 1 + .../src/Models/RecoveryPlanReadinessError.cs | 9 + .../RecoveryPlanReadinessFailedResource.cs | 11 + .../Models/RecoveryPlanReadinessFailedTask.cs | 10 + .../src/Models/RecoveryPlanReadinessResult.cs | 13 ++ .../RecoveryPlanCheckReadinessOptions.cs | 22 ++ .../src/ResilienceManagementSetup.cs | 2 + .../Services/IResilienceManagementService.cs | 2 + .../Services/ResilienceManagementService.cs | 220 ++++++++++++++++++ .../RecoveryPlanCheckReadinessCommandTests.cs | 154 ++++++++++++ .../ResilienceManagementCommandTests.cs | 22 ++ .../ResilienceManagementServiceTests.cs | 53 +++++ 16 files changed, 603 insertions(+) create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessError.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedResource.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedTask.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessResult.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOptions.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs diff --git a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml index 628ce4b7a2..95457c05af 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml @@ -5,3 +5,5 @@ changes: description: "Added the 'azmcp resilience recovery plan delete' command to delete a recovery plan from an Azure service group. The command is idempotent and reports whether a plan was deleted." - section: "Features Added" description: "Added the 'azmcp resilience recovery plan update-resources' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings. The command validates mandatory protection settings before first inclusion while preserving existing settings on sparse updates." + - section: "Features Added" + description: "Added the 'azmcp resilience recovery plan check-readiness' command to discover and assess whether a recovery plan and its protected resources are ready for recovery operations. The command waits for the readiness job and returns its status, errors, failed tasks, and failed resources." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 748bcaf41d..095188ac7b 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3752,6 +3752,11 @@ azmcp resilience recovery plan update-resources --service-group [--resources-to-update ''] \ [--resources-to-remove ''] +# Discover and assess whether a recovery plan and its protected resources are ready for recovery operations. Waits for the readiness job to finish and returns its status, errors, failed tasks, and failed resources. +# ❌ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp resilience recovery plan check-readiness --service-group \ + --recovery-plan + # Get a resource (member) of a recovery plan, or list all resources of the plan (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recovery plan resource get --subscription \ diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 38dc135143..50557a342c 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -924,6 +924,8 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_recovery_plan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | | resilience_recovery_plan_create | Change a system-assigned recovery plan in service group to use a user-assigned managed identity | clarification-required | | resilience_recovery_plan_create | Update recovery plan in service group to use both its system-assigned identity and user-assigned managed identity . Preserve its existing plan settings | none | +| resilience_recovery_plan_check-readiness | Check whether recovery plan and its protected resources are ready for recovery operations in service group | none | +| resilience_recovery_plan_check-readiness | Discover readiness issues for the resources in recovery plan in service group | none | | resilience_recovery_plan_delete | Delete the entire recovery plan from service group | none | | resilience_recovery_plan_delete | Recovery plan is no longer needed. Delete it from resilience service group | none | | resilience_recovery_plan_get | List all resilience recovery plans in service group | none | diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs new file mode 100644 index 0000000000..92b8898149 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tools.ResilienceManagement.Models; +using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; + +[CommandMetadata( + Id = "6f991f5e-0218-46b5-8d6d-8b59defb1143", + Name = "check-readiness", + Title = "Check Resilience Recovery Plan Readiness", + Description = "Checks whether a resilience recovery plan and its protected resources are ready for recovery operations in an Azure service group.", + Destructive = false, + Idempotent = false, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class RecoveryPlanCheckReadinessCommand(ILogger logger, IResilienceManagementService resilienceManagementService) + : AuthenticatedCommand +{ + private readonly ILogger _logger = logger; + private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService; + + public override void ValidateOptions(RecoveryPlanCheckReadinessOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + RecoveryPlanValidation.ValidateName(options.RecoveryPlan, validationResult); + } + + public override async Task ExecuteAsync(CommandContext context, RecoveryPlanCheckReadinessOptions options, CancellationToken cancellationToken) + { + try + { + RecoveryPlanReadinessResult result = await _resilienceManagementService.CheckRecoveryPlanReadinessAsync( + options.ServiceGroup, + options.RecoveryPlan, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + result, + ResilienceManagementJsonContext.Default.RecoveryPlanReadinessResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error checking recovery plan readiness. ServiceGroup: {ServiceGroup}, RecoveryPlan: {RecoveryPlan}.", + options.ServiceGroup, options.RecoveryPlan); + HandleException(context, ex); + } + + return context.Response; + } + + protected override string GetErrorMessage(Exception ex) => ex switch + { + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => + "The recovery plan readiness check cannot start in its current state. Complete or cancel active recovery operations and try again.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed checking recovery plan readiness. Verify you have permission to run recovery plan actions in the service group.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Recovery plan not found. Verify the recovery plan and service group exist and you have access.", + RequestFailedException => + "The recovery plan readiness request failed. Verify the recovery plan, service group, and request parameters, then try again.", + _ => base.GetErrorMessage(ex) + }; +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs index 81e66a4b37..05f50d5f12 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs @@ -53,6 +53,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands; [JsonSerializable(typeof(RecoveryPlanGroupInfo))] [JsonSerializable(typeof(RecoveryPlanDeleteCommand.RecoveryPlanDeleteCommandResult))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesCommand.RecoveryPlanUpdateResourcesCommandResult))] +[JsonSerializable(typeof(RecoveryPlanReadinessResult))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesResult))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesFailedResource))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesError))] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessError.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessError.cs new file mode 100644 index 0000000000..66fa457b84 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessError.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanReadinessError( + string? Code, + string? Message, + IReadOnlyList Recommendations); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedResource.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedResource.cs new file mode 100644 index 0000000000..0156e1ccb4 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedResource.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanReadinessFailedResource( + string Id, + string? ResourceId, + string Status, + string? TaskName, + RecoveryPlanReadinessError? Error); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedTask.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedTask.cs new file mode 100644 index 0000000000..6d5829e044 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedTask.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanReadinessFailedTask( + string? TaskId, + string? TaskName, + string Status, + RecoveryPlanReadinessError? Error); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessResult.cs new file mode 100644 index 0000000000..7eb50db1f9 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanReadinessResult( + string OperationId, + string RecoveryJobId, + bool? IsReady, + string Status, + RecoveryPlanReadinessError? Error, + List FailedTasks, + List FailedResources); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOptions.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOptions.cs new file mode 100644 index 0000000000..ae35d37328 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Options; +using Microsoft.Mcp.Core.Options; + +namespace Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; + +public sealed class RecoveryPlanCheckReadinessOptions +{ + [Option(Description = ResilienceManagementOptionDescriptions.ServiceGroup)] + public required string ServiceGroup { get; set; } + + [Option(Description = "The name of the recovery plan whose resources will be assessed for readiness.")] + public required string RecoveryPlan { get; set; } + + [Option(Description = OptionDescriptions.Tenant)] + public string? Tenant { get; set; } + + [OptionContainer(Prefix = "retry")] + public RetryPolicyOptions? RetryPolicy { get; set; } +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs index dd04a145e7..011e319127 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs @@ -40,6 +40,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -104,6 +105,7 @@ and high availability and disaster recovery requirements. recoveryPlans.AddCommand(serviceProvider); recoveryPlans.AddCommand(serviceProvider); recoveryPlans.AddCommand(serviceProvider); + recoveryPlans.AddCommand(serviceProvider); // Create resource subgroup under recovery plan var recoveryResources = new CommandGroup("resource", "Resilience recovery resource operations - Commands for listing and getting the resources (members) of a resilience recovery plan."); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index d94ebed658..8ff67f84d6 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -42,6 +42,8 @@ public interface IResilienceManagementService Task UpdateRecoveryPlanResourcesAsync(string serviceGroup, string recoveryPlan, UpdateRecoveryResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task CheckRecoveryPlanReadinessAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task> ListRecoveryResourcesAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetRecoveryResourceAsync(string serviceGroup, string recoveryPlan, string recoveryResource, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index c19a74d858..1bcb9b9987 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -17,6 +17,9 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Services; public sealed class ResilienceManagementService(IAzureService azureService) : BaseAzureResourceService(azureService), IResilienceManagementService { + private static readonly TimeSpan ReadinessJobPollingInterval = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ReadinessJobTimeout = TimeSpan.FromMinutes(10); + public async Task> ListGoalTemplatesAsync(string serviceGroup, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); @@ -514,6 +517,223 @@ public async Task UpdateRecoveryPlanResources return CreateRecoveryPlanUpdateResourcesResult(operation.Value.FailedResources); } + public async Task CheckRecoveryPlanReadinessAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + { + ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); + + var recoveryPlanId = RecoveryPlanResource.CreateResourceIdentifier(serviceGroup, recoveryPlan); + RecoveryPlanResource recoveryPlanResource = await armClient.GetRecoveryPlanResource(recoveryPlanId).GetAsync(cancellationToken); + string operationId = Guid.NewGuid().ToString(); + ArmOperation operation = await recoveryPlanResource.CheckReadinessAsync(WaitUntil.Completed, operationId, cancellationToken); + string recoveryJobId = GetRecoveryJobName(operation.GetRawResponse()); + + var recoveryJobResourceId = RecoveryJobResource.CreateResourceIdentifier(serviceGroup, recoveryPlan, recoveryJobId); + RecoveryJobResource recoveryJob = armClient.GetRecoveryJobResource(recoveryJobResourceId); + RecoveryJobResource recoveryJobResource = await WaitForRecoveryJobCompletionAsync(recoveryJob, recoveryJobId, cancellationToken); + + RecoveryJobProperties recoveryJobProperties = recoveryJobResource.Data.Properties; + string status = recoveryJobProperties.Status?.ToString() ?? string.Empty; + RecoveryPlanReadinessError? error = CreateReadinessError(recoveryJobProperties.ErrorDetails); + List failedTasks = GetFailedTasks(recoveryJobProperties.JobExtendedInfo?.TasksList); + List failedResources = []; + + await foreach (RecoveryJobTargetResource recoveryJobTarget in recoveryJobResource.GetRecoveryJobTargets().GetAllAsync(cancellationToken: cancellationToken)) + { + RecoveryJobResourceProperties properties = recoveryJobTarget.Data.Properties; + string targetStatus = properties.Status?.ToString() ?? string.Empty; + if (!string.Equals(targetStatus, "Completed", StringComparison.OrdinalIgnoreCase)) + { + failedResources.Add(new RecoveryPlanReadinessFailedResource( + recoveryJobTarget.Data.Id?.ToString() ?? string.Empty, + properties.ResourceId?.ToString(), + targetStatus, + properties.TaskName, + CreateReadinessError(properties.ErrorDetails))); + } + } + + bool isReady = string.Equals(status, "Completed", StringComparison.OrdinalIgnoreCase) && + failedTasks.Count == 0 && failedResources.Count == 0; + + return new RecoveryPlanReadinessResult( + operationId, + recoveryJobId, + isReady, + status, + error, + failedTasks, + failedResources); + } + + private static string GetRecoveryJobName(Response response) + { + using JsonDocument document = JsonDocument.Parse(response.Content.ToMemory()); + if (TryFindStringProperty(document.RootElement, "jobId", out string? recoveryJobId) && recoveryJobId is not null) + { + return recoveryJobId.StartsWith("/", StringComparison.Ordinal) + ? new ResourceIdentifier(recoveryJobId).Name + : recoveryJobId; + } + + throw new InvalidOperationException("The readiness operation completed without returning a recovery job identifier."); + } + + private static bool TryFindStringProperty(JsonElement element, string propertyName, out string? value) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase) && property.Value.ValueKind == JsonValueKind.String) + { + value = property.Value.GetString(); + return !string.IsNullOrWhiteSpace(value); + } + + if (TryFindStringProperty(property.Value, propertyName, out value)) + { + return true; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + if (TryFindStringProperty(item, propertyName, out value)) + { + return true; + } + } + } + else if (element.ValueKind == JsonValueKind.String) + { + string? serializedValue = element.GetString(); + if (!string.IsNullOrWhiteSpace(serializedValue) && serializedValue.TrimStart().StartsWith('{')) + { + try + { + using JsonDocument nestedDocument = JsonDocument.Parse(serializedValue); + return TryFindStringProperty(nestedDocument.RootElement, propertyName, out value); + } + catch (JsonException) + { + } + } + } + + value = null; + return false; + } + + private static bool IsTerminalJobStatus(string status) => + status.Equals("Completed", StringComparison.OrdinalIgnoreCase) || + status.Equals("Failed", StringComparison.OrdinalIgnoreCase) || + status.Equals("Cancelled", StringComparison.OrdinalIgnoreCase); + + private static async Task WaitForRecoveryJobCompletionAsync( + RecoveryJobResource recoveryJob, + string recoveryJobId, + CancellationToken cancellationToken) + { + return await WaitForCompletionAsync( + token => GetRecoveryJobIfAvailableAsync(recoveryJob, token), + job => IsTerminalJobStatus(job.Data.Properties.Status?.ToString() ?? string.Empty), + $"readiness recovery job '{recoveryJobId}'", + ReadinessJobPollingInterval, + ReadinessJobTimeout, + cancellationToken); + } + + internal static async Task WaitForCompletionAsync( + Func> getCurrent, + Func isComplete, + string operationDescription, + TimeSpan pollingInterval, + TimeSpan timeout, + CancellationToken cancellationToken) + where T : class + { + using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCancellation.CancelAfter(timeout); + + try + { + while (true) + { + T? current = await getCurrent(timeoutCancellation.Token); + if (current is not null && isComplete(current)) + { + return current; + } + + await Task.Delay(pollingInterval, timeoutCancellation.Token); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"The {operationDescription} did not complete within {timeout.TotalMinutes} minutes."); + } + } + + private static async Task GetRecoveryJobIfAvailableAsync( + RecoveryJobResource recoveryJob, + CancellationToken cancellationToken) + { + try + { + Response response = await recoveryJob.GetAsync(cancellationToken); + return response.Value; + } + catch (ArgumentNullException ex) when (ex.ParamName == "id") + { + return null; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + private static RecoveryPlanReadinessError? CreateReadinessError(JobErrorInfo? error) + { + return error is null + ? null + : new RecoveryPlanReadinessError(error.ErrorCode, error.ErrorMessage, error.Recommendations); + } + + private static List GetFailedTasks(IReadOnlyList? tasks) + { + var failedTasks = new List(); + if (tasks is not null) + { + AddFailedTasks(tasks, failedTasks); + } + + return failedTasks; + } + + private static void AddFailedTasks(IReadOnlyList tasks, List failedTasks) + { + foreach (JobTaskDetail task in tasks) + { + string status = task.Status?.ToString() ?? string.Empty; + if (string.Equals(status, "Failed", StringComparison.OrdinalIgnoreCase)) + { + failedTasks.Add(new RecoveryPlanReadinessFailedTask( + task.TaskId, + task.TaskName, + status, + CreateReadinessError(task.ErrorDetails))); + } + + if (task.SubTasksList.Count > 0) + { + AddFailedTasks(task.SubTasksList, failedTasks); + } + } + } + internal static RecoveryPlanUpdateResourcesResult CreateRecoveryPlanUpdateResourcesResult(IEnumerable failedResources) { List resources = failedResources.Select(resource => diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs new file mode 100644 index 0000000000..36e1dd0c1b --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure.Mcp.Tools.ResilienceManagement.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; +using Azure.Mcp.Tools.ResilienceManagement.Models; +using Azure.Mcp.Tools.ResilienceManagement.Services; +using Microsoft.Mcp.Core.Options; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Recovery.Plans; + +public sealed class RecoveryPlanCheckReadinessCommandTests : CommandUnitTestsBase +{ + private const string ValidArgs = "--service-group sg1 --recovery-plan plan1"; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + Assert.Equal("check-readiness", command.Name); + Assert.Contains("protected resources", command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(ValidArgs, true)] + [InlineData("--service-group sg1", false)] + [InlineData("--recovery-plan plan1", false)] + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.CheckRecoveryPlanReadinessAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(CreateSuccessfulResult()); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName() + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "invalid_plan"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("5 to 24 characters", response.Message, StringComparison.OrdinalIgnoreCase); + await Service.DidNotReceive().CheckRecoveryPlanReadinessAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ReturnsCompletedReadinessResult() + { + Service.CheckRecoveryPlanReadinessAsync( + "sg1", + "plan1", + null, + null, + Arg.Any()) + .Returns(CreateSuccessfulResult()); + + var response = await ExecuteCommandAsync(ValidArgs); + + var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.RecoveryPlanReadinessResult); + Assert.Equal("operation-1", result.OperationId); + Assert.Equal("job-1", result.RecoveryJobId); + Assert.True(result.IsReady); + Assert.Equal("Completed", result.Status); + } + + [Fact] + public async Task ExecuteAsync_ReturnsReadinessFailures() + { + var error = new RecoveryPlanReadinessError("NotReady", "Resource requires attention.", ["Include or exclude the resource."]); + Service.CheckRecoveryPlanReadinessAsync( + "sg1", + "plan1", + null, + null, + Arg.Any()) + .Returns(new RecoveryPlanReadinessResult( + "operation-1", + "job-1", + false, + "Failed", + error, + [new("task-1", "ApplicationModificationTask", "Failed", error)], + [new("target-1", "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/disks/disk1", "Failed", "ApplicationModificationTask", error)])); + + var response = await ExecuteCommandAsync(ValidArgs); + + var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.RecoveryPlanReadinessResult); + Assert.False(result.IsReady); + Assert.Equal("Failed", result.Status); + RecoveryPlanReadinessFailedTask failedTask = Assert.Single(result.FailedTasks); + Assert.Equal("ApplicationModificationTask", failedTask.TaskName); + Assert.Equal("NotReady", failedTask.Error?.Code); + RecoveryPlanReadinessFailedResource failedResource = Assert.Single(result.FailedResources); + Assert.EndsWith("/disks/disk1", failedResource.ResourceId, StringComparison.Ordinal); + Assert.Equal("ApplicationModificationTask", failedResource.TaskName); + Assert.Equal("NotReady", failedResource.Error?.Code); + Assert.Equal("NotReady", result.Error?.Code); + } + + [Theory] + [InlineData(HttpStatusCode.Conflict, "current state")] + [InlineData(HttpStatusCode.Forbidden, "Authorization failed")] + [InlineData(HttpStatusCode.NotFound, "not found")] + [InlineData(HttpStatusCode.BadRequest, "request failed")] + public async Task ExecuteAsync_SanitizesRequestFailedException(HttpStatusCode status, string expectedMessage) + { + const string providerDetails = "Sensitive provider details: request-id=123; endpoint=https://example.invalid"; + Service.CheckRecoveryPlanReadinessAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException((int)status, providerDetails)); + + var response = await ExecuteCommandAsync(ValidArgs); + + Assert.Equal(status, response.Status); + Assert.Contains(expectedMessage, response.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(providerDetails, response.Message); + } + + private static RecoveryPlanReadinessResult CreateSuccessfulResult() => new( + "operation-1", + "job-1", + true, + "Completed", + null, + [], + []); +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index dd5e154eba..9d202f9be9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -282,6 +282,28 @@ public async Task Should_update_recovery_plan() plan.AssertProperty("additionalGroups").EnumerateArray().Select(group => group.GetRawText())); } + [Fact] + [CustomMatcher(compareBody: false)] + public async Task Should_check_recovery_plan_readiness() + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); + var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); + + var result = await CallToolAsync( + "resilience_recovery_plan_check-readiness", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "recovery-plan", recoveryPlan } + }); + + Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); + Assert.True(Guid.TryParse(result.AssertProperty("recoveryJobId").GetString(), out _)); + Assert.False(string.IsNullOrWhiteSpace(result.AssertProperty("status").GetString())); + Assert.True(result.AssertProperty("isReady").ValueKind is JsonValueKind.True or JsonValueKind.False); + } + [Fact] [CustomMatcher(compareBody: false)] public async Task Should_create_update_and_delete_recovery_plan() diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index 44a7ba4149..1d3dbe5dcd 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -16,6 +16,59 @@ public sealed class ResilienceManagementServiceTests { private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testIdentity"; + [Fact] + public async Task WaitForCompletionAsync_RetriesUntilCompletion() + { + var states = new Queue([null, "InProgress", "Completed"]); + int attempts = 0; + + string result = await ResilienceManagementService.WaitForCompletionAsync( + _ => + { + attempts++; + return Task.FromResult(states.Dequeue()); + }, + state => state == "Completed", + "test operation", + TimeSpan.FromMilliseconds(1), + TimeSpan.FromSeconds(1), + CancellationToken.None); + + Assert.Equal("Completed", result); + Assert.Equal(3, attempts); + } + + [Fact] + public async Task WaitForCompletionAsync_TimesOut() + { + TimeoutException exception = await Assert.ThrowsAsync(() => + ResilienceManagementService.WaitForCompletionAsync( + _ => Task.FromResult(null), + _ => false, + "test operation", + TimeSpan.FromMilliseconds(1), + TimeSpan.FromMilliseconds(20), + CancellationToken.None)); + + Assert.Contains("test operation", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task WaitForCompletionAsync_PreservesCallerCancellation() + { + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + ResilienceManagementService.WaitForCompletionAsync( + _ => Task.FromResult(null), + _ => false, + "test operation", + TimeSpan.FromMilliseconds(1), + TimeSpan.FromSeconds(1), + cancellation.Token)); + } + [Fact] public void CreateRecoveryGroupsSetting_ForNewPlan_GeneratesDefaultGroupId() { From 76cc8157025b80dd14e9e94778197e6a9975337c Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Thu, 20 Aug 2026 00:16:21 +0530 Subject: [PATCH 24/38] up --- .../Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index 0e015b2f67..bd1a68f272 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", - "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_1479f47486" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_05f2ade713" } \ No newline at end of file From 074b6debe31c97133a883f2138b073a10ec160b2 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Thu, 20 Aug 2026 00:43:42 +0530 Subject: [PATCH 25/38] up --- .../src/Resources/consolidated-tools.json | 33 +++++++++++++++++++ .../Plans/RecoveryPlanCreateCommand.cs | 2 +- ...cs => RecoveryPlanCheckReadinessOption.cs} | 0 3 files changed, 34 insertions(+), 1 deletion(-) rename tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/{RecoveryPlanCheckReadinessOptions.cs => RecoveryPlanCheckReadinessOption.cs} (100%) diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index aa114ed636..311fd417b7 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -80,6 +80,39 @@ "resilience_recovery_plan_update-resources" ] }, + { + "name": "check_azure_resilience_management_readiness", + "description": "Check whether an Azure Resilience Management recovery plan and its protected resources are ready for recovery operations in an Azure service group.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool does not delete or modify the recovery plan or its protected resources." + }, + "idempotent": { + "value": false, + "description": "Each invocation creates a new readiness job with a unique operation identifier." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool creates a readiness job while leaving the recovery plan and its protected resources unchanged." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "resilience_recovery_plan_check-readiness" + ] + }, { "name": "get_azure_subscriptions_and_resource_groups", "description": "Get information about Azure subscriptions, resource groups, and resources within resource groups that the user has access to.", diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index 0fd0a872ff..01301d4ae4 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -128,5 +128,5 @@ public override async Task ExecuteAsync(CommandContext context, _ => base.GetErrorMessage(ex) }; - public record RecoveryPlanCreateCommandResult(Models.RecoveryPlanInfo RecoveryPlan); + public sealed record RecoveryPlanCreateCommandResult(Models.RecoveryPlanInfo RecoveryPlan); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOptions.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOption.cs similarity index 100% rename from tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOptions.cs rename to tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOption.cs From bf2ed1a685974eb9e297521ab40b61679ea5aa39 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Thu, 20 Aug 2026 00:48:07 +0530 Subject: [PATCH 26/38] up --- .../Plans/RecoveryPlanUpdateResourcesCommand.cs | 4 +++- .../RecoveryPlanUpdateResourcesCommandTests.cs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs index f77f336259..f5308d0cbb 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -3,6 +3,7 @@ using System.ClientModel.Primitives; using System.Net; +using System.Text; using System.Text.Json; using Azure.Mcp.Core.Commands; using Azure.Mcp.Tools.ResilienceManagement.Models; @@ -90,7 +91,8 @@ internal static UpdateRecoveryResourcesContent CreateContent(RecoveryPlanUpdateR throw new ArgumentException("Specify at least one of --resources-to-update or --resources-to-remove."); } - if (options.ResourcesToUpdate?.Length > MaxPayloadLength || options.ResourcesToRemove?.Length > MaxPayloadLength) + if ((options.ResourcesToUpdate is { } updatesJson && Encoding.UTF8.GetByteCount(updatesJson) > MaxPayloadLength) || + (options.ResourcesToRemove is { } removalsJson && Encoding.UTF8.GetByteCount(removalsJson) > MaxPayloadLength)) { throw new ArgumentException("Each recovery resource JSON payload must not exceed 1 MB."); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs index d8b672ec1b..b30f161105 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs @@ -5,6 +5,7 @@ using Azure.Mcp.Tools.ResilienceManagement.Commands; using Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; using Azure.Mcp.Tools.ResilienceManagement.Models; +using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; using Azure.Mcp.Tools.ResilienceManagement.Services; using Azure.ResourceManager.ResilienceManagement.Models; using Microsoft.Mcp.Core.Options; @@ -52,6 +53,21 @@ public async Task ExecuteAsync_RequiresAnUpdateOrRemoval() Assert.Contains("at least one", response.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void CreateContent_RejectsUtf8PayloadOverOneMegabyte() + { + var options = new RecoveryPlanUpdateResourcesOptions + { + ServiceGroup = "sg1", + RecoveryPlan = "plan1", + ResourcesToUpdate = $"[\"{new string('é', 524_288)}\"]" + }; + + var exception = Assert.Throws(() => _ = RecoveryPlanUpdateResourcesCommand.CreateContent(options)); + + Assert.Contains("must not exceed 1 MB", exception.Message); + } + [Fact] public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName() { From 605de0a08712a2eaea5a3c8c29efbbe834c27e26 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Thu, 20 Aug 2026 01:14:01 +0530 Subject: [PATCH 27/38] up --- .../Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs | 2 +- .../src/Models/RecoveryPlanReadinessError.cs | 2 +- .../src/Models/RecoveryPlanReadinessFailedResource.cs | 2 +- .../src/Models/RecoveryPlanReadinessFailedTask.cs | 2 +- .../src/Models/RecoveryPlanReadinessResult.cs | 2 +- .../Options/Recovery/Plans/RecoveryPlanCheckReadinessOption.cs | 2 +- .../Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs index 92b8898149..757549ea42 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs @@ -72,4 +72,4 @@ public override async Task ExecuteAsync(CommandContext context, "The recovery plan readiness request failed. Verify the recovery plan, service group, and request parameters, then try again.", _ => base.GetErrorMessage(ex) }; -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessError.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessError.cs index 66fa457b84..4a65514fb0 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessError.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessError.cs @@ -6,4 +6,4 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models; public sealed record RecoveryPlanReadinessError( string? Code, string? Message, - IReadOnlyList Recommendations); \ No newline at end of file + IReadOnlyList Recommendations); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedResource.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedResource.cs index 0156e1ccb4..89852c93cb 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedResource.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedResource.cs @@ -8,4 +8,4 @@ public sealed record RecoveryPlanReadinessFailedResource( string? ResourceId, string Status, string? TaskName, - RecoveryPlanReadinessError? Error); \ No newline at end of file + RecoveryPlanReadinessError? Error); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedTask.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedTask.cs index 6d5829e044..bcf44204d1 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedTask.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessFailedTask.cs @@ -7,4 +7,4 @@ public sealed record RecoveryPlanReadinessFailedTask( string? TaskId, string? TaskName, string Status, - RecoveryPlanReadinessError? Error); \ No newline at end of file + RecoveryPlanReadinessError? Error); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessResult.cs index 7eb50db1f9..09293432df 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessResult.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanReadinessResult.cs @@ -10,4 +10,4 @@ public sealed record RecoveryPlanReadinessResult( string Status, RecoveryPlanReadinessError? Error, List FailedTasks, - List FailedResources); \ No newline at end of file + List FailedResources); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOption.cs index ae35d37328..6d0566fc67 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCheckReadinessOption.cs @@ -19,4 +19,4 @@ public sealed class RecoveryPlanCheckReadinessOptions [OptionContainer(Prefix = "retry")] public RetryPolicyOptions? RetryPolicy { get; set; } -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs index 36e1dd0c1b..262176aef7 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs @@ -151,4 +151,4 @@ public async Task ExecuteAsync_SanitizesRequestFailedException(HttpStatusCode st null, [], []); -} \ No newline at end of file +} From 983ae389b37e9215e81e961fd391d0c6f951386e Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Thu, 20 Aug 2026 23:19:19 +0530 Subject: [PATCH 28/38] fixes --- eng/tools/VallyEvaluator/src/Program.cs | 23 +---- .../tests/VallyUtilitiesTests.cs | 13 --- .../resilience-recovery-plan-commands.yaml | 2 +- .../Azure.Mcp.Server/docs/azmcp-commands.md | 4 +- .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 4 +- .../src/Resources/consolidated-tools.json | 64 ++++++------- .../RecoveryPlanCheckReadinessCommand.cs | 14 ++- .../RecoveryPlanUpdateResourcesCommand.cs | 4 +- .../Services/ResilienceManagementService.cs | 93 ++++++++----------- .../RecoveryPlanCheckReadinessCommandTests.cs | 59 +++++++++++- ...RecoveryPlanUpdateResourcesCommandTests.cs | 16 ++++ .../ResilienceManagementCommandTests.cs | 2 +- .../ResilienceManagementServiceTests.cs | 56 +++++++++++ 13 files changed, 225 insertions(+), 129 deletions(-) diff --git a/eng/tools/VallyEvaluator/src/Program.cs b/eng/tools/VallyEvaluator/src/Program.cs index 8d17a6c1b2..6954e8c9ca 100644 --- a/eng/tools/VallyEvaluator/src/Program.cs +++ b/eng/tools/VallyEvaluator/src/Program.cs @@ -10,12 +10,6 @@ namespace VallyEvaluator; internal class Program { - private static readonly IReadOnlyDictionary s_promptNamespaceAliases = - new Dictionary(StringComparer.InvariantCultureIgnoreCase) - { - ["resiliencemanagement"] = "resilience" - }; - public static async Task Main(string[] args) { var configuration = new ConfigurationBuilder() @@ -114,11 +108,10 @@ internal static List GetToolNamespacesFromBuildInfo(BuildInfo buildInfo, var lastPeriod = split[1].LastIndexOf('.'); var possibleNamespace = split[1].Substring(lastPeriod + 1).ToLowerInvariant(); - var promptNamespace = ResolvePromptNamespace(possibleNamespace, promptNamespaces); - if (promptNamespace != null) + if (promptNamespaces.Contains(possibleNamespace)) { - results.Add(promptNamespace); + results.Add(possibleNamespace); } else if (mcpServerInformation != null) { @@ -140,18 +133,6 @@ internal static List GetToolNamespacesFromBuildInfo(BuildInfo buildInfo, return results.ToList(); } - internal static string? ResolvePromptNamespace(string possibleNamespace, IReadOnlySet promptNamespaces) - { - if (promptNamespaces.Contains(possibleNamespace)) - { - return possibleNamespace; - } - - return s_promptNamespaceAliases.TryGetValue(possibleNamespace, out var alias) && promptNamespaces.Contains(alias) - ? alias - : null; - } - internal static Task GetMcpServerInfo(RunConfiguration configuration, BuildInfo buildInfo) { var serverInfo = buildInfo.Data.Servers.FirstOrDefault(x => x.Name.Equals(configuration.ServerName, StringComparison.OrdinalIgnoreCase)); diff --git a/eng/tools/VallyEvaluator/tests/VallyUtilitiesTests.cs b/eng/tools/VallyEvaluator/tests/VallyUtilitiesTests.cs index ac67b4964c..9d8cace9f5 100644 --- a/eng/tools/VallyEvaluator/tests/VallyUtilitiesTests.cs +++ b/eng/tools/VallyEvaluator/tests/VallyUtilitiesTests.cs @@ -8,19 +8,6 @@ namespace VallyEvaluator.Tests; public sealed class VallyUtilitiesTests { - [Fact] - public void ResolvePromptNamespace_MapsResilienceManagementPackageToResilienceNamespace() - { - IReadOnlySet promptNamespaces = new HashSet(StringComparer.InvariantCultureIgnoreCase) - { - "resilience" - }; - - var result = Program.ResolvePromptNamespace("resiliencemanagement", promptNamespaces); - - Assert.Equal("resilience", result); - } - [Fact] public void GetToolCallGrader_CreatesRequiredCommandEntry() { diff --git a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml index 393863cea0..2a20c8a064 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml @@ -6,4 +6,4 @@ changes: - section: "Features Added" description: "Added the 'azmcp resilience recovery plan resource update' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings. The command validates mandatory protection settings before first inclusion while preserving existing settings on sparse updates." - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan check-readiness' command to discover and assess whether a recovery plan and its protected resources are ready for recovery operations. The command waits for the readiness job and returns its status, errors, failed tasks, and failed resources." + description: "Added the 'azmcp resilience recovery plan checkreadiness' command to discover and assess whether a recovery plan and its protected resources are ready for recovery operations. The command waits for the readiness job and returns its status, errors, failed tasks, and failed resources." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 9834d65297..6c2a342ad5 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3755,8 +3755,8 @@ azmcp resilience recovery plan resource update --service-group \ # Discover and assess whether a recovery plan and its protected resources are ready for recovery operations. Waits for the readiness job to finish and returns its status, errors, failed tasks, and failed resources. # ❌ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery plan check-readiness --service-group \ - --recovery-plan +azmcp resilience recovery plan checkreadiness --service-group \ + --recovery-plan # Get a resource (member) of a recovery plan, or list all resources of the plan (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 4d1a912686..de8cbcdf66 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -926,8 +926,8 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_recovery_plan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | | resilience_recovery_plan_create | Change a system-assigned recovery plan in service group to use a user-assigned managed identity | clarification-required | | resilience_recovery_plan_create | Update recovery plan in service group to use both its system-assigned identity and user-assigned managed identity . Preserve its existing plan settings | none | -| resilience_recovery_plan_check-readiness | Check whether recovery plan and its protected resources are ready for recovery operations in service group | none | -| resilience_recovery_plan_check-readiness | Discover readiness issues for the resources in recovery plan in service group | none | +| resilience_recovery_plan_checkreadiness | Check whether recovery plan and its protected resources are ready for recovery operations in service group | none | +| resilience_recovery_plan_checkreadiness | Discover readiness issues for the resources in recovery plan in service group | none | | resilience_recovery_plan_delete | Delete the entire recovery plan from service group | none | | resilience_recovery_plan_delete | Recovery plan is no longer needed. Delete it from resilience service group | none | | resilience_recovery_plan_get | List all resilience recovery plans in service group | none | diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 96d41111ca..b50458f07f 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -110,7 +110,7 @@ } }, "mappedToolList": [ - "resilience_recovery_plan_check-readiness" + "resilience_recovery_plan_checkreadiness" ] }, { @@ -923,37 +923,37 @@ ] }, { - "name": "apply_azure_advisor_recommendations", - "description": "Get rules that can help apply Advisor recommendation to create or modify IaaC files (like ARM, Bicep) for Azure resources.", - "toolMetadata": { - "destructive": { - "value": false, - "description": "This tool performs only additive updates without deleting or modifying existing resources." - }, - "idempotent": { - "value": true, - "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." - }, - "openWorld": { - "value": false, - "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." - }, - "readOnly": { - "value": true, - "description": "This tool only performs read operations without modifying any state or data." - }, - "secret": { - "value": false, - "description": "This tool does not handle sensitive or secret information." - }, - "localRequired": { - "value": false, - "description": "This tool is available in both local and remote server modes." - } - }, - "mappedToolList": [ - "advisor_recommendation_apply" - ] + "name": "apply_azure_advisor_recommendations", + "description": "Get rules that can help apply Advisor recommendation to create or modify IaaC files (like ARM, Bicep) for Azure resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "advisor_recommendation_apply" + ] }, { "name": "get_azure_retail_pricing", diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs index 757549ea42..81ac094546 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs @@ -13,7 +13,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; [CommandMetadata( Id = "6f991f5e-0218-46b5-8d6d-8b59defb1143", - Name = "check-readiness", + Name = "checkreadiness", Title = "Check Resilience Recovery Plan Readiness", Description = "Checks whether a resilience recovery plan and its protected resources are ready for recovery operations in an Azure service group.", Destructive = false, @@ -31,6 +31,7 @@ public sealed class RecoveryPlanCheckReadinessCommand(ILogger ExecuteAsync(CommandContext context, protected override string GetErrorMessage(Exception ex) => ex switch { + TimeoutException => + "The recovery plan readiness check timed out before it completed. Retry the operation.", + InvalidOperationException => + "The recovery plan readiness check completed without returning a recovery job identifier. Retry the operation. If the problem persists, contact support.", RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => "The recovery plan readiness check cannot start in its current state. Complete or cancel active recovery operations and try again.", RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => @@ -72,4 +77,11 @@ public override async Task ExecuteAsync(CommandContext context, "The recovery plan readiness request failed. Verify the recovery plan, service group, and request parameters, then try again.", _ => base.GetErrorMessage(ex) }; + + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch + { + TimeoutException => HttpStatusCode.GatewayTimeout, + InvalidOperationException => HttpStatusCode.BadGateway, + _ => base.GetStatusCode(ex) + }; } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs index c5b72329ce..416fee245c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanUpdateResourcesCommand.cs @@ -3,6 +3,7 @@ using System.ClientModel.Primitives; using System.Net; +using System.Text; using System.Text.Json; using Azure.Mcp.Core.Commands; using Azure.Mcp.Tools.ResilienceManagement.Models; @@ -91,7 +92,8 @@ internal static UpdateRecoveryResourcesContent CreateContent(RecoveryPlanUpdateR throw new ArgumentException("Specify at least one of --resources-to-update or --resources-to-remove."); } - if (options.ResourcesToUpdate?.Length > MaxPayloadLength || options.ResourcesToRemove?.Length > MaxPayloadLength) + if ((options.ResourcesToUpdate is { } updatesJson && Encoding.UTF8.GetByteCount(updatesJson) > MaxPayloadLength) || + (options.ResourcesToRemove is { } removalsJson && Encoding.UTF8.GetByteCount(removalsJson) > MaxPayloadLength)) { throw new ArgumentException("Each recovery resource JSON payload must not exceed 1 MB."); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 7f6002eeb1..0bb04bd0b2 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -18,7 +18,7 @@ public sealed class ResilienceManagementService(IAzureService azureService) : BaseAzureResourceService(azureService), IResilienceManagementService { private static readonly TimeSpan ReadinessJobPollingInterval = TimeSpan.FromSeconds(30); - private static readonly TimeSpan ReadinessJobTimeout = TimeSpan.FromMinutes(10); + private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromMinutes(10); public async Task> ListGoalTemplatesAsync(string serviceGroup, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { @@ -517,7 +517,16 @@ public async Task UpdateRecoveryPlanResources return CreateRecoveryPlanUpdateResourcesResult(operation.Value.FailedResources); } - public async Task CheckRecoveryPlanReadinessAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + public Task CheckRecoveryPlanReadinessAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + { + return ExecuteWithTimeoutAsync( + token => CheckRecoveryPlanReadinessCoreAsync(serviceGroup, recoveryPlan, tenant, retryPolicy, token), + "recovery plan readiness check", + ReadinessTimeout, + cancellationToken); + } + + private async Task CheckRecoveryPlanReadinessCoreAsync(string serviceGroup, string recoveryPlan, string? tenant, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); @@ -525,7 +534,7 @@ public async Task CheckRecoveryPlanReadinessAsync(s RecoveryPlanResource recoveryPlanResource = await armClient.GetRecoveryPlanResource(recoveryPlanId).GetAsync(cancellationToken); string operationId = Guid.NewGuid().ToString(); ArmOperation operation = await recoveryPlanResource.CheckReadinessAsync(WaitUntil.Completed, operationId, cancellationToken); - string recoveryJobId = GetRecoveryJobName(operation.GetRawResponse()); + string recoveryJobId = GetRecoveryJobName(operation.GetRawResponse().Content); var recoveryJobResourceId = RecoveryJobResource.CreateResourceIdentifier(serviceGroup, recoveryPlan, recoveryJobId); RecoveryJobResource recoveryJob = armClient.GetRecoveryJobResource(recoveryJobResourceId); @@ -565,65 +574,39 @@ public async Task CheckRecoveryPlanReadinessAsync(s failedResources); } - private static string GetRecoveryJobName(Response response) + internal static async Task ExecuteWithTimeoutAsync( + Func> operation, + string operationDescription, + TimeSpan timeout, + CancellationToken cancellationToken) { - using JsonDocument document = JsonDocument.Parse(response.Content.ToMemory()); - if (TryFindStringProperty(document.RootElement, "jobId", out string? recoveryJobId) && recoveryJobId is not null) - { - return recoveryJobId.StartsWith("/", StringComparison.Ordinal) - ? new ResourceIdentifier(recoveryJobId).Name - : recoveryJobId; - } - - throw new InvalidOperationException("The readiness operation completed without returning a recovery job identifier."); - } + using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCancellation.CancelAfter(timeout); - private static bool TryFindStringProperty(JsonElement element, string propertyName, out string? value) - { - if (element.ValueKind == JsonValueKind.Object) + try { - foreach (JsonProperty property in element.EnumerateObject()) - { - if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase) && property.Value.ValueKind == JsonValueKind.String) - { - value = property.Value.GetString(); - return !string.IsNullOrWhiteSpace(value); - } - - if (TryFindStringProperty(property.Value, propertyName, out value)) - { - return true; - } - } + return await operation(timeoutCancellation.Token); } - else if (element.ValueKind == JsonValueKind.Array) + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - foreach (JsonElement item in element.EnumerateArray()) - { - if (TryFindStringProperty(item, propertyName, out value)) - { - return true; - } - } + throw new TimeoutException($"The {operationDescription} did not complete within {timeout.TotalMinutes} minutes."); } - else if (element.ValueKind == JsonValueKind.String) + } + + internal static string GetRecoveryJobName(BinaryData responseContent) + { + RecoveryPlanActionBaseResult? result = ModelReaderWriter.Read( + responseContent, + ModelReaderWriterOptions.Json, + AzureResourceManagerResilienceManagementContext.Default); + if (!string.IsNullOrWhiteSpace(result?.JobId)) { - string? serializedValue = element.GetString(); - if (!string.IsNullOrWhiteSpace(serializedValue) && serializedValue.TrimStart().StartsWith('{')) - { - try - { - using JsonDocument nestedDocument = JsonDocument.Parse(serializedValue); - return TryFindStringProperty(nestedDocument.RootElement, propertyName, out value); - } - catch (JsonException) - { - } - } + return result.JobId.StartsWith("/", StringComparison.Ordinal) + ? new ResourceIdentifier(result.JobId).Name + : result.JobId; } - value = null; - return false; + throw new InvalidOperationException("The readiness operation completed without returning a recovery job identifier."); } private static bool IsTerminalJobStatus(string status) => @@ -641,7 +624,7 @@ private static async Task WaitForRecoveryJobCompletionAsync job => IsTerminalJobStatus(job.Data.Properties.Status?.ToString() ?? string.Empty), $"readiness recovery job '{recoveryJobId}'", ReadinessJobPollingInterval, - ReadinessJobTimeout, + ReadinessTimeout, cancellationToken); } @@ -687,6 +670,8 @@ internal static async Task WaitForCompletionAsync( } catch (ArgumentNullException ex) when (ex.ParamName == "id") { + // A newly created job can return a successful response before its required resource ID is populated. + // The generated SDK throws while constructing RecoveryJobResource; retry until the job materializes. return null; } catch (RequestFailedException ex) when (ex.Status == 404) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs index 262176aef7..480ccd4bcf 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs @@ -22,7 +22,7 @@ public sealed class RecoveryPlanCheckReadinessCommandTests : CommandUnitTestsBas public void Constructor_InitializesCommandCorrectly() { var command = Command.GetCommand(); - Assert.Equal("check-readiness", command.Name); + Assert.Equal("checkreadiness", command.Name); Assert.Contains("protected resources", command.Description, StringComparison.OrdinalIgnoreCase); } @@ -66,6 +66,23 @@ await Service.DidNotReceive().CheckRecoveryPlanReadinessAsync( Arg.Any()); } + [Fact] + public async Task ExecuteAsync_RejectsInvalidServiceGroupName() + { + var response = await ExecuteCommandAsync( + "--service-group", "../sg1", + "--recovery-plan", "plan1"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("service group name", response.Message, StringComparison.OrdinalIgnoreCase); + await Service.DidNotReceive().CheckRecoveryPlanReadinessAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + [Fact] public async Task ExecuteAsync_ReturnsCompletedReadinessResult() { @@ -143,6 +160,46 @@ public async Task ExecuteAsync_SanitizesRequestFailedException(HttpStatusCode st Assert.DoesNotContain(providerDetails, response.Message); } + [Fact] + public async Task ExecuteAsync_MapsTimeoutExceptionToGatewayTimeout() + { + const string internalDetails = "Internal polling timeout details"; + Service.CheckRecoveryPlanReadinessAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new TimeoutException(internalDetails)); + + var response = await ExecuteCommandAsync(ValidArgs); + + Assert.Equal(HttpStatusCode.GatewayTimeout, response.Status); + Assert.Contains("timed out", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Retry", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(internalDetails, response.Message); + } + + [Fact] + public async Task ExecuteAsync_MapsMissingJobIdToBadGateway() + { + const string internalDetails = "Internal response parsing details"; + Service.CheckRecoveryPlanReadinessAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new InvalidOperationException(internalDetails)); + + var response = await ExecuteCommandAsync(ValidArgs); + + Assert.Equal(HttpStatusCode.BadGateway, response.Status); + Assert.Contains("without returning a recovery job identifier", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Retry", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(internalDetails, response.Message); + } + private static RecoveryPlanReadinessResult CreateSuccessfulResult() => new( "operation-1", "job-1", diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs index 91ed3759f3..66742421f5 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanUpdateResourcesCommandTests.cs @@ -5,6 +5,7 @@ using Azure.Mcp.Tools.ResilienceManagement.Commands; using Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; using Azure.Mcp.Tools.ResilienceManagement.Models; +using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; using Azure.Mcp.Tools.ResilienceManagement.Services; using Azure.ResourceManager.ResilienceManagement.Models; using Microsoft.Mcp.Core.Options; @@ -52,6 +53,21 @@ public async Task ExecuteAsync_RequiresAnUpdateOrRemoval() Assert.Contains("at least one", response.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void CreateContent_RejectsUtf8PayloadOverOneMegabyte() + { + var options = new RecoveryPlanUpdateResourcesOptions + { + ServiceGroup = "sg1", + RecoveryPlan = "plan1", + ResourcesToUpdate = $"[\"{new string('\u00E9', 524_288)}\"]" + }; + + var exception = Assert.Throws(() => _ = RecoveryPlanUpdateResourcesCommand.CreateContent(options)); + + Assert.Contains("must not exceed 1 MB", exception.Message); + } + [Fact] public async Task ExecuteAsync_RejectsInvalidRecoveryPlanName() { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index b2f3d5579b..9cf438cb6f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -290,7 +290,7 @@ public async Task Should_check_recovery_plan_readiness() var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); var result = await CallToolAsync( - "resilience_recovery_plan_check-readiness", + "resilience_recovery_plan_checkreadiness", new() { { "tenant", Settings.TenantId }, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index d62d96eeb3..fa13079a74 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -16,6 +16,62 @@ public sealed class ResilienceManagementServiceTests { private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testIdentity"; + [Fact] + public void GetRecoveryJobName_ReadsTypedTopLevelJobId() + { + string result = ResilienceManagementService.GetRecoveryJobName(BinaryData.FromString(""" + {"jobId":"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryJobs/job1"} + """)); + + Assert.Equal("job1", result); + } + + [Fact] + public void GetRecoveryJobName_RejectsNestedJobId() + { + var exception = Assert.Throws(() => + ResilienceManagementService.GetRecoveryJobName(BinaryData.FromString(""" + {"details":{"jobId":"wrong-job"}} + """))); + + Assert.Contains("without returning a recovery job identifier", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteWithTimeoutAsync_TimesOutOperation() + { + TimeoutException exception = await Assert.ThrowsAsync(() => + ResilienceManagementService.ExecuteWithTimeoutAsync( + async token => + { + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return "completed"; + }, + "readiness operation", + TimeSpan.FromMilliseconds(20), + CancellationToken.None)); + + Assert.Contains("readiness operation", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecuteWithTimeoutAsync_PreservesCallerCancellation() + { + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + ResilienceManagementService.ExecuteWithTimeoutAsync( + async token => + { + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return "completed"; + }, + "readiness operation", + TimeSpan.FromSeconds(1), + cancellation.Token)); + } + [Fact] public async Task WaitForCompletionAsync_RetriesUntilCompletion() { From 05cc1bb2e3677771d77d286bf598e64f109a2747 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Fri, 21 Aug 2026 19:13:46 +0530 Subject: [PATCH 29/38] Refactor resilience recovery plan identifiers and improve error handling - Updated JSON resource identifiers for recovery plans and jobs to use a consistent naming convention (e.g., "resilience_recovery_plan_get" to "resilience_recoveryplan_get"). - Enhanced error messages in the RecoveryPlanCheckReadinessCommand to provide clearer feedback on readiness check failures. - Refactored ResilienceManagementService to improve handling of recovery job identifiers, including new methods for validation and retrieval. - Added tests to ensure proper functionality of recovery job identifier handling and error conditions. - Updated PowerShell scripts to include role assignments for recovery contributors and improved provisioning checks. - Modified Bicep templates to ensure deterministic naming for resources based on the resource group name. --- .../resilience-recovery-plan-commands.yaml | 10 +- .../Azure.Mcp.Server/docs/azmcp-commands.md | 30 ++-- .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 48 +++---- .../src/Resources/consolidated-tools.json | 16 +-- .../RecoveryPlanCheckReadinessCommand.cs | 2 +- .../src/ResilienceManagementSetup.cs | 15 +- .../Services/ResilienceManagementService.cs | 125 ++++++++++++---- .../RecoveryPlanCheckReadinessCommandTests.cs | 2 +- .../ResilienceManagementCommandTests.cs | 38 ++--- .../ResilienceManagementServiceTests.cs | 136 ++++++++++++++++-- .../assets.json | 2 +- .../tests/test-resources-post.ps1 | 116 ++++++++++----- .../tests/test-resources.bicep | 4 +- 13 files changed, 395 insertions(+), 149 deletions(-) diff --git a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml index 2a20c8a064..80e8365621 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml @@ -1,9 +1,11 @@ changes: - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan create' command to create or fully update a Zonal recovery plan with an explicitly selected system-assigned, user-assigned, or combined identity. Updates can switch identity types while preserving existing recovery groups." + description: "Added the 'azmcp resilience recoveryplan create' command to create or fully update a Zonal recovery plan with an explicitly selected system-assigned, user-assigned, or combined identity. Updates can switch identity types while preserving existing recovery groups." - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan delete' command to delete a recovery plan from an Azure service group. The command is idempotent and reports whether a plan was deleted." + description: "Added the 'azmcp resilience recoveryplan delete' command to delete a recovery plan from an Azure service group. The command is idempotent and reports whether a plan was deleted." - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan resource update' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings. The command validates mandatory protection settings before first inclusion while preserving existing settings on sparse updates." + description: "Added the 'azmcp resilience recoveryplan resource update' command to include, exclude, configure, or remove recovery resources, including their recovery groups, associated identities, and protection settings. The command validates mandatory protection settings before first inclusion while preserving existing settings on sparse updates." - section: "Features Added" - description: "Added the 'azmcp resilience recovery plan checkreadiness' command to discover and assess whether a recovery plan and its protected resources are ready for recovery operations. The command waits for the readiness job and returns its status, errors, failed tasks, and failed resources." + description: "Added the 'azmcp resilience recoveryplan checkreadiness' command to discover and assess whether a recovery plan and its protected resources are ready for recovery operations. The command waits for the readiness job and returns its status, errors, failed tasks, and failed resources." + - section: "Breaking Changes" + description: "Renamed the recovery plan command group from 'azmcp resilience recovery plan' to 'azmcp resilience recoveryplan' and the recovery job command group from 'azmcp resilience recovery job' to 'azmcp resilience recoveryjob'. Existing get commands now use the corresponding single-word resource group names." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 6c2a342ad5..26911e4d6b 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3722,13 +3722,13 @@ azmcp resilience usageplan enrollment create --subscription \ # Get a resilience recovery plan, or list all recovery plans in a service group (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery plan get --subscription \ +azmcp resilience recoveryplan get --subscription \ --service-group \ [--name ] # Create or fully update a Zonal resilience recovery plan. Ask the customer to select an identity type; do not assume SystemAssigned or another default. Identity types can switch on update, but an existing user-assigned identity cannot be replaced with a different user-assigned identity. The plan description must be 5 to 50 characters and is required on create; it is preserved when omitted on update. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery plan create --service-group \ +azmcp resilience recoveryplan create --service-group \ --recovery-plan \ --plan-type Zonal \ [--plan-description ] \ @@ -3741,44 +3741,44 @@ azmcp resilience recovery plan create --service-group \ # Delete a resilience recovery plan. Returns deleted=false when the plan does not exist. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery plan delete --service-group \ +azmcp resilience recoveryplan delete --service-group \ --recovery-plan # Configure recovery-plan resource inclusions, exclusions, removals, recovery groups, identities, and protection settings. At least one JSON array is required. # First inclusion requires matching protection type and settings. CustomRunbook requires failover and reprotect runbook resource IDs. # AzureSiteRecovery is supported for virtual machines and requires disk reprotect details. Existing configuration is preserved on sparse updates. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery plan resource update --service-group \ +azmcp resilience recoveryplan resource update --service-group \ --recovery-plan \ [--resources-to-update ''] \ [--resources-to-remove ''] # Discover and assess whether a recovery plan and its protected resources are ready for recovery operations. Waits for the readiness job to finish and returns its status, errors, failed tasks, and failed resources. # ❌ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery plan checkreadiness --service-group \ +azmcp resilience recoveryplan checkreadiness --service-group \ --recovery-plan # Get a resource (member) of a recovery plan, or list all resources of the plan (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery plan resource get --subscription \ +azmcp resilience recoveryplan resource get --subscription \ --service-group \ --recovery-plan \ [--name ] # Get a recovery job, or list all recovery jobs of a recovery plan (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery job get --subscription \ - --service-group \ - --recovery-plan \ - [--name ] +azmcp resilience recoveryjob get --subscription \ + --service-group \ + --recovery-plan \ + [--name ] # Get a resource (target) of a recovery job, or list all resources of the job (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired -azmcp resilience recovery job resource get --subscription \ - --service-group \ - --recovery-plan \ - --recovery-job \ - [--name ] +azmcp resilience recoveryjob resource get --subscription \ + --service-group \ + --recovery-plan \ + --recovery-job \ + [--name ] # Get a resilience drill, or list all drills in a service group (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index de8cbcdf66..2560a5809d 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -916,30 +916,30 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_goal_resource_get | Get the goal resource for goal assignment in service group | none | | resilience_goal_template_get | List all resilience goal templates in service group | none | | resilience_goal_template_get | Get the details of goal template in service group | none | -| resilience_recovery_job_get | List all recovery jobs of recovery plan in service group | none | -| resilience_recovery_job_get | Get the details of recovery job for recovery plan in service group | none | -| resilience_recovery_job_resource_get | List all resources (targets) of recovery job for recovery plan in service group | none | -| resilience_recovery_job_resource_get | Get the recovery job resource for recovery job of recovery plan in service group | none | -| resilience_recovery_plan_create | Create a Zonal recovery plan named in service group | clarification-required | -| resilience_recovery_plan_create | Set up a Zonal recovery plan named in service group . Use a system-assigned managed identity, description , and default recovery group description | none | -| resilience_recovery_plan_create | Create Zonal recovery plan in service group and attach user-assigned managed identity . Use for the plan description and for the default recovery group | none | -| resilience_recovery_plan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | -| resilience_recovery_plan_create | Change a system-assigned recovery plan in service group to use a user-assigned managed identity | clarification-required | -| resilience_recovery_plan_create | Update recovery plan in service group to use both its system-assigned identity and user-assigned managed identity . Preserve its existing plan settings | none | -| resilience_recovery_plan_checkreadiness | Check whether recovery plan and its protected resources are ready for recovery operations in service group | none | -| resilience_recovery_plan_checkreadiness | Discover readiness issues for the resources in recovery plan in service group | none | -| resilience_recovery_plan_delete | Delete the entire recovery plan from service group | none | -| resilience_recovery_plan_delete | Recovery plan is no longer needed. Delete it from resilience service group | none | -| resilience_recovery_plan_get | List all resilience recovery plans in service group | none | -| resilience_recovery_plan_get | Get the details of recovery plan in service group | none | -| resilience_recovery_plan_resource_update | Include and configure recovery resource in recovery plan in service group with selected protection solution type and settings | none | -| resilience_recovery_plan_resource_update | Add recovery resource to recovery plan in service group . Protect it with CustomRunbook using failover runbook and reprotect runbook | none | -| resilience_recovery_plan_resource_update | Include virtual machine recovery resource in recovery plan in service group using AzureSiteRecovery protection settings with disk reprotection, staging storage, and a test failover virtual network | none | -| resilience_recovery_plan_resource_update | Include recovery resource in recovery plan in service group , but I have not chosen CustomRunbook or AzureSiteRecovery protection settings | clarification-required | -| resilience_recovery_plan_resource_update | Keep recovery resource in recovery plan in service group , but exclude it from recovery operations | none | -| resilience_recovery_plan_resource_update | Update recovery plan in service group by removing recovery resource from its resource membership while retaining the recovery plan and its other recovery resources | none | -| resilience_recovery_plan_resource_get | List all resources (members) of recovery plan in service group | none | -| resilience_recovery_plan_resource_get | Get the recovery resource for recovery plan in service group | none | +| resilience_recoveryjob_get | List all recovery jobs of recovery plan in service group | none | +| resilience_recoveryjob_get | Get the details of recovery job for recovery plan in service group | none | +| resilience_recoveryjob_resource_get | List all resources (targets) of recovery job for recovery plan in service group | none | +| resilience_recoveryjob_resource_get | Get the recovery job resource for recovery job of recovery plan in service group | none | +| resilience_recoveryplan_create | Create a Zonal recovery plan named in service group | clarification-required | +| resilience_recoveryplan_create | Set up a Zonal recovery plan named in service group . Use a system-assigned managed identity, description , and default recovery group description | none | +| resilience_recoveryplan_create | Create Zonal recovery plan in service group and attach user-assigned managed identity . Use for the plan description and for the default recovery group | none | +| resilience_recoveryplan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | +| resilience_recoveryplan_create | Change a system-assigned recovery plan in service group to use a user-assigned managed identity | clarification-required | +| resilience_recoveryplan_create | Update recovery plan in service group to use both its system-assigned identity and user-assigned managed identity . Preserve its existing plan settings | none | +| resilience_recoveryplan_checkreadiness | Check whether recovery plan and its protected resources are ready for recovery operations in service group | none | +| resilience_recoveryplan_checkreadiness | Discover readiness issues for the resources in recovery plan in service group | none | +| resilience_recoveryplan_delete | Delete the entire recovery plan from service group | none | +| resilience_recoveryplan_delete | Recovery plan is no longer needed. Delete it from resilience service group | none | +| resilience_recoveryplan_get | List all resilience recovery plans in service group | none | +| resilience_recoveryplan_get | Get the details of recovery plan in service group | none | +| resilience_recoveryplan_resource_update | Include and configure recovery resource in recovery plan in service group with selected protection solution type and settings | none | +| resilience_recoveryplan_resource_update | Add recovery resource to recovery plan in service group . Protect it with CustomRunbook using failover runbook and reprotect runbook | none | +| resilience_recoveryplan_resource_update | Include virtual machine recovery resource in recovery plan in service group using AzureSiteRecovery protection settings with disk reprotection, staging storage, and a test failover virtual network | none | +| resilience_recoveryplan_resource_update | Include recovery resource in recovery plan in service group , but I have not chosen CustomRunbook or AzureSiteRecovery protection settings | clarification-required | +| resilience_recoveryplan_resource_update | Keep recovery resource in recovery plan in service group , but exclude it from recovery operations | none | +| resilience_recoveryplan_resource_update | Update recovery plan in service group by removing recovery resource from its resource membership while retaining the recovery plan and its other recovery resources | none | +| resilience_recoveryplan_resource_get | List all resources (members) of recovery plan in service group | none | +| resilience_recoveryplan_resource_get | Get the recovery resource for recovery plan in service group | none | | resilience_usageplan_create | Create a resilience usage plan with plan type Basic in resource group | none | | resilience_usageplan_create | Set up a Basic resilience usage plan named in resource group | none | | resilience_usageplan_create | Update resilience usage plan in resource group to use the Basic plan type | none | diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index b50458f07f..6e8dced7d4 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -35,10 +35,10 @@ "resilience_goal_resource_get", "resilience_usageplan_get", "resilience_usageplan_enrollment_get", - "resilience_recovery_plan_get", - "resilience_recovery_plan_resource_get", - "resilience_recovery_job_get", - "resilience_recovery_job_resource_get", + "resilience_recoveryplan_get", + "resilience_recoveryplan_resource_get", + "resilience_recoveryjob_get", + "resilience_recoveryjob_resource_get", "resilience_drill_get", "resilience_drill_resource_get" ] @@ -75,9 +75,9 @@ "mappedToolList": [ "resilience_usageplan_create", "resilience_usageplan_enrollment_create", - "resilience_recovery_plan_create", - "resilience_recovery_plan_delete", - "resilience_recovery_plan_resource_update" + "resilience_recoveryplan_create", + "resilience_recoveryplan_delete", + "resilience_recoveryplan_resource_update" ] }, { @@ -110,7 +110,7 @@ } }, "mappedToolList": [ - "resilience_recovery_plan_checkreadiness" + "resilience_recoveryplan_checkreadiness" ] }, { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs index 81ac094546..8a38f9da90 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCheckReadinessCommand.cs @@ -66,7 +66,7 @@ public override async Task ExecuteAsync(CommandContext context, TimeoutException => "The recovery plan readiness check timed out before it completed. Retry the operation.", InvalidOperationException => - "The recovery plan readiness check completed without returning a recovery job identifier. Retry the operation. If the problem persists, contact support.", + "The recovery plan readiness check completed without returning a valid recovery job response. Retry the operation. If the problem persists, contact support.", RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict => "The recovery plan readiness check cannot start in its current state. Complete or cancel active recovery operations and try again.", RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs index 8e192e2ef1..0b3ea24521 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs @@ -94,12 +94,9 @@ and high availability and disaster recovery requirements. enrollments.AddCommand(serviceProvider); enrollments.AddCommand(serviceProvider); - // Create recovery subgroup with a plan subgroup - var recovery = new CommandGroup("recovery", "Resilience recovery operations - Commands for working with resilience recovery plans for an Azure service group."); - resilienceManagement.AddSubGroup(recovery); - - var recoveryPlans = new CommandGroup("plan", "Resilience recovery plan operations - Commands for listing and getting resilience recovery plans for an Azure service group."); - recovery.AddSubGroup(recoveryPlans); + // Create recoveryplan subgroup + var recoveryPlans = new CommandGroup("recoveryplan", "Resilience recovery plan operations - Commands for listing and getting resilience recovery plans for an Azure service group."); + resilienceManagement.AddSubGroup(recoveryPlans); recoveryPlans.AddCommand(serviceProvider); recoveryPlans.AddCommand(serviceProvider); @@ -113,9 +110,9 @@ and high availability and disaster recovery requirements. recoveryResources.AddCommand(serviceProvider); recoveryResources.AddCommand(serviceProvider); - // Create job subgroup under recovery - var recoveryJobs = new CommandGroup("job", "Resilience recovery job operations - Commands for listing and getting the recovery jobs of a resilience recovery plan."); - recovery.AddSubGroup(recoveryJobs); + // Create recoveryjob subgroup + var recoveryJobs = new CommandGroup("recoveryjob", "Resilience recovery job operations - Commands for listing and getting the recovery jobs of a resilience recovery plan."); + resilienceManagement.AddSubGroup(recoveryJobs); recoveryJobs.AddCommand(serviceProvider); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 0bb04bd0b2..648112256c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -532,15 +532,23 @@ private async Task CheckRecoveryPlanReadinessCoreAs var recoveryPlanId = RecoveryPlanResource.CreateResourceIdentifier(serviceGroup, recoveryPlan); RecoveryPlanResource recoveryPlanResource = await armClient.GetRecoveryPlanResource(recoveryPlanId).GetAsync(cancellationToken); + RecoveryJobCollection recoveryJobs = recoveryPlanResource.GetRecoveryJobs(); + HashSet existingRecoveryJobIds = await GetRecoveryJobIdsAsync(recoveryJobs, cancellationToken); string operationId = Guid.NewGuid().ToString(); ArmOperation operation = await recoveryPlanResource.CheckReadinessAsync(WaitUntil.Completed, operationId, cancellationToken); - string recoveryJobId = GetRecoveryJobName(operation.GetRawResponse().Content); + ResourceIdentifier recoveryJobResourceId = TryGetRecoveryJobResourceId( + operation.GetRawResponse().Content, + serviceGroup, + recoveryPlan) + ?? await WaitForNewReadinessJobAsync(recoveryJobs, existingRecoveryJobIds, cancellationToken); + string recoveryJobId = recoveryJobResourceId.Name; - var recoveryJobResourceId = RecoveryJobResource.CreateResourceIdentifier(serviceGroup, recoveryPlan, recoveryJobId); RecoveryJobResource recoveryJob = armClient.GetRecoveryJobResource(recoveryJobResourceId); RecoveryJobResource recoveryJobResource = await WaitForRecoveryJobCompletionAsync(recoveryJob, recoveryJobId, cancellationToken); - RecoveryJobProperties recoveryJobProperties = recoveryJobResource.Data.Properties; + RecoveryJobProperties recoveryJobProperties = GetRequiredProperties( + recoveryJobResource.Data.Properties, + "recovery job"); string status = recoveryJobProperties.Status?.ToString() ?? string.Empty; RecoveryPlanReadinessError? error = CreateReadinessError(recoveryJobProperties.ErrorDetails); List failedTasks = GetFailedTasks(recoveryJobProperties.JobExtendedInfo?.TasksList); @@ -548,7 +556,9 @@ private async Task CheckRecoveryPlanReadinessCoreAs await foreach (RecoveryJobTargetResource recoveryJobTarget in recoveryJobResource.GetRecoveryJobTargets().GetAllAsync(cancellationToken: cancellationToken)) { - RecoveryJobResourceProperties properties = recoveryJobTarget.Data.Properties; + RecoveryJobResourceProperties properties = GetRequiredProperties( + recoveryJobTarget.Data.Properties, + "recovery job target"); string targetStatus = properties.Status?.ToString() ?? string.Empty; if (!string.Equals(targetStatus, "Completed", StringComparison.OrdinalIgnoreCase)) { @@ -587,31 +597,98 @@ internal static async Task ExecuteWithTimeoutAsync( { return await operation(timeoutCancellation.Token); } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (timeoutCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { throw new TimeoutException($"The {operationDescription} did not complete within {timeout.TotalMinutes} minutes."); } } - internal static string GetRecoveryJobName(BinaryData responseContent) + internal static ResourceIdentifier GetRecoveryJobResourceId(BinaryData responseContent, string serviceGroup, string recoveryPlan) { - RecoveryPlanActionBaseResult? result = ModelReaderWriter.Read( - responseContent, - ModelReaderWriterOptions.Json, - AzureResourceManagerResilienceManagementContext.Default); - if (!string.IsNullOrWhiteSpace(result?.JobId)) + return TryGetRecoveryJobResourceId(responseContent, serviceGroup, recoveryPlan) + ?? throw new InvalidOperationException("The readiness operation completed without returning a recovery job identifier."); + } + + internal static ResourceIdentifier? TryGetRecoveryJobResourceId(BinaryData responseContent, string serviceGroup, string recoveryPlan) + { + try + { + RecoveryPlanActionBaseResult? result = ModelReaderWriter.Read( + responseContent, + ModelReaderWriterOptions.Json, + AzureResourceManagerResilienceManagementContext.Default); + if (string.IsNullOrWhiteSpace(result?.JobId)) + { + return null; + } + + ResourceIdentifier recoveryJobResourceId = result.JobId.StartsWith("/", StringComparison.Ordinal) + ? new ResourceIdentifier(result.JobId) + : RecoveryJobResource.CreateResourceIdentifier(serviceGroup, recoveryPlan, result.JobId); + var expectedJobId = RecoveryJobResource.CreateResourceIdentifier(serviceGroup, recoveryPlan, recoveryJobResourceId.Name); + if (!Guid.TryParseExact(recoveryJobResourceId.Name, "D", out _) || + recoveryJobResourceId.ResourceType != RecoveryJobResource.ResourceType || + !string.Equals(recoveryJobResourceId.Parent?.ToString(), expectedJobId.Parent?.ToString(), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The readiness operation returned an invalid recovery job identifier."); + } + + return recoveryJobResourceId; + } + catch (Exception ex) when (ex is JsonException or FormatException or ArgumentException) + { + throw new InvalidOperationException("The readiness operation returned an invalid recovery job identifier.", ex); + } + } + + private static async Task> GetRecoveryJobIdsAsync(RecoveryJobCollection recoveryJobs, CancellationToken cancellationToken) + { + HashSet recoveryJobIds = new(StringComparer.OrdinalIgnoreCase); + await foreach (RecoveryJobResource recoveryJob in recoveryJobs.GetAllAsync(cancellationToken: cancellationToken)) { - return result.JobId.StartsWith("/", StringComparison.Ordinal) - ? new ResourceIdentifier(result.JobId).Name - : result.JobId; + recoveryJobIds.Add(recoveryJob.Data.Id.ToString()); } - throw new InvalidOperationException("The readiness operation completed without returning a recovery job identifier."); + return recoveryJobIds; + } + + private static async Task WaitForNewReadinessJobAsync( + RecoveryJobCollection recoveryJobs, + HashSet existingRecoveryJobIds, + CancellationToken cancellationToken) + { + while (true) + { + List matchingJobIds = []; + await foreach (RecoveryJobResource recoveryJob in recoveryJobs.GetAllAsync(cancellationToken: cancellationToken)) + { + if (!existingRecoveryJobIds.Contains(recoveryJob.Data.Id.ToString()) && + string.Equals(recoveryJob.Data.Properties?.Operation, "CheckRecoveryPlanReadiness", StringComparison.OrdinalIgnoreCase)) + { + matchingJobIds.Add(recoveryJob.Data.Id); + } + } + + if (matchingJobIds.Count == 1) + { + return matchingJobIds[0]; + } + + if (matchingJobIds.Count > 1) + { + throw new InvalidOperationException("Multiple recovery jobs were created by concurrent readiness operations."); + } + + await Task.Delay(ReadinessJobPollingInterval, cancellationToken); + } } - private static bool IsTerminalJobStatus(string status) => + internal static bool IsTerminalJobStatus(string status) => + status.Equals("NotApplicable", StringComparison.OrdinalIgnoreCase) || status.Equals("Completed", StringComparison.OrdinalIgnoreCase) || + status.Equals("CompletedWithWarnings", StringComparison.OrdinalIgnoreCase) || status.Equals("Failed", StringComparison.OrdinalIgnoreCase) || + status.Equals("Skipped", StringComparison.OrdinalIgnoreCase) || status.Equals("Cancelled", StringComparison.OrdinalIgnoreCase); private static async Task WaitForRecoveryJobCompletionAsync( @@ -621,13 +698,19 @@ private static async Task WaitForRecoveryJobCompletionAsync { return await WaitForCompletionAsync( token => GetRecoveryJobIfAvailableAsync(recoveryJob, token), - job => IsTerminalJobStatus(job.Data.Properties.Status?.ToString() ?? string.Empty), + job => IsTerminalJobStatus(GetRequiredProperties(job.Data.Properties, "recovery job").Status?.ToString() ?? string.Empty), $"readiness recovery job '{recoveryJobId}'", ReadinessJobPollingInterval, ReadinessTimeout, cancellationToken); } + internal static T GetRequiredProperties(T? properties, string resourceDescription) where T : class + { + return properties ?? throw new InvalidOperationException( + $"The readiness operation returned a {resourceDescription} without required properties."); + } + internal static async Task WaitForCompletionAsync( Func> getCurrent, Func isComplete, @@ -653,7 +736,7 @@ internal static async Task WaitForCompletionAsync( await Task.Delay(pollingInterval, timeoutCancellation.Token); } } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (timeoutCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { throw new TimeoutException($"The {operationDescription} did not complete within {timeout.TotalMinutes} minutes."); } @@ -668,12 +751,6 @@ internal static async Task WaitForCompletionAsync( Response response = await recoveryJob.GetAsync(cancellationToken); return response.Value; } - catch (ArgumentNullException ex) when (ex.ParamName == "id") - { - // A newly created job can return a successful response before its required resource ID is populated. - // The generated SDK throws while constructing RecoveryJobResource; retry until the job materializes. - return null; - } catch (RequestFailedException ex) when (ex.Status == 404) { return null; diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs index 480ccd4bcf..a8162e6f9c 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCheckReadinessCommandTests.cs @@ -195,7 +195,7 @@ public async Task ExecuteAsync_MapsMissingJobIdToBadGateway() var response = await ExecuteCommandAsync(ValidArgs); Assert.Equal(HttpStatusCode.BadGateway, response.Status); - Assert.Contains("without returning a recovery job identifier", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("without returning a valid recovery job response", response.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("Retry", response.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(internalDetails, response.Message); } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index 9cf438cb6f..57068a26bd 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -217,7 +217,7 @@ public async Task Should_get_recovery_plan() var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); var result = await CallToolAsync( - "resilience_recovery_plan_get", + "resilience_recoveryplan_get", new() { { "tenant", Settings.TenantId }, @@ -235,7 +235,7 @@ public async Task Should_update_recovery_plan() var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); var existingResult = await CallToolAsync( - "resilience_recovery_plan_get", + "resilience_recoveryplan_get", new() { { "tenant", Settings.TenantId }, @@ -260,7 +260,7 @@ public async Task Should_update_recovery_plan() Assert.False(string.IsNullOrEmpty(defaultGroupDescription)); var result = await CallToolAsync( - "resilience_recovery_plan_create", + "resilience_recoveryplan_create", new() { { "tenant", Settings.TenantId }, @@ -290,7 +290,7 @@ public async Task Should_check_recovery_plan_readiness() var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); var result = await CallToolAsync( - "resilience_recovery_plan_checkreadiness", + "resilience_recoveryplan_checkreadiness", new() { { "tenant", Settings.TenantId }, @@ -315,7 +315,7 @@ public async Task Should_create_update_and_delete_recovery_plan() try { var createResult = await CallToolAsync( - "resilience_recovery_plan_create", + "resilience_recoveryplan_create", new() { { "tenant", Settings.TenantId }, @@ -337,7 +337,7 @@ public async Task Should_create_update_and_delete_recovery_plan() Assert.Equal("Lifecycle default group", createdDefaultGroup.AssertProperty("description").GetString()); var getResult = await CallToolAsync( - "resilience_recovery_plan_get", + "resilience_recoveryplan_get", new() { { "tenant", Settings.TenantId }, @@ -349,7 +349,7 @@ public async Task Should_create_update_and_delete_recovery_plan() getResult.AssertProperty("recoveryPlan").AssertProperty("id").GetString()); var updateResult = await CallToolAsync( - "resilience_recovery_plan_create", + "resilience_recoveryplan_create", new() { { "tenant", Settings.TenantId }, @@ -368,7 +368,7 @@ public async Task Should_create_update_and_delete_recovery_plan() Assert.Equal("Lifecycle default group", updatedDefaultGroup.AssertProperty("description").GetString()); var deleteResult = await CallToolAsync( - "resilience_recovery_plan_delete", + "resilience_recoveryplan_delete", new() { { "tenant", Settings.TenantId }, @@ -380,7 +380,7 @@ public async Task Should_create_update_and_delete_recovery_plan() Assert.Equal(recoveryPlan, deleteResult.AssertProperty("recoveryPlan").GetString()); var repeatedDeleteResult = await CallToolAsync( - "resilience_recovery_plan_delete", + "resilience_recoveryplan_delete", new() { { "tenant", Settings.TenantId }, @@ -394,7 +394,7 @@ public async Task Should_create_update_and_delete_recovery_plan() if (recoveryPlanExists) { await CallToolAsync( - "resilience_recovery_plan_delete", + "resilience_recoveryplan_delete", new() { { "tenant", Settings.TenantId }, @@ -412,7 +412,7 @@ public async Task Should_list_recovery_resources() var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); var result = await CallToolAsync( - "resilience_recovery_plan_resource_get", + "resilience_recoveryplan_resource_get", new() { { "tenant", Settings.TenantId }, @@ -430,7 +430,7 @@ public async Task Should_update_recovery_plan_resources() var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); var listedResources = await CallToolAsync( - "resilience_recovery_plan_resource_get", + "resilience_recoveryplan_resource_get", new() { { "tenant", Settings.TenantId }, @@ -443,7 +443,7 @@ public async Task Should_update_recovery_plan_resources() Assert.False(string.IsNullOrEmpty(resourceName)); var resourceResult = await CallToolAsync( - "resilience_recovery_plan_resource_get", + "resilience_recoveryplan_resource_get", new() { { "tenant", Settings.TenantId }, @@ -466,7 +466,7 @@ public async Task Should_update_recovery_plan_resources() }; var result = await CallToolAsync( - "resilience_recovery_plan_resource_update", + "resilience_recoveryplan_resource_update", new() { { "tenant", Settings.TenantId }, @@ -480,7 +480,7 @@ public async Task Should_update_recovery_plan_resources() Assert.Empty(failedResources.EnumerateArray()); var updatedResourceResult = await CallToolAsync( - "resilience_recovery_plan_resource_get", + "resilience_recoveryplan_resource_get", new() { { "tenant", Settings.TenantId }, @@ -503,7 +503,7 @@ public async Task Should_get_recovery_job() var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); var listResult = await CallToolAsync( - "resilience_recovery_job_get", + "resilience_recoveryjob_get", new() { { "tenant", Settings.TenantId }, @@ -518,7 +518,7 @@ public async Task Should_get_recovery_job() recoveryJobs.EnumerateArray().First().AssertProperty("name").GetString()!); var result = await CallToolAsync( - "resilience_recovery_job_get", + "resilience_recoveryjob_get", new() { { "tenant", Settings.TenantId }, @@ -538,7 +538,7 @@ public async Task Should_list_recovery_job_resources() var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); var listResult = await CallToolAsync( - "resilience_recovery_job_get", + "resilience_recoveryjob_get", new() { { "tenant", Settings.TenantId }, @@ -553,7 +553,7 @@ public async Task Should_list_recovery_job_resources() recoveryJobs.EnumerateArray().First().AssertProperty("name").GetString()!); var result = await CallToolAsync( - "resilience_recovery_job_resource_get", + "resilience_recoveryjob_resource_get", new() { { "tenant", Settings.TenantId }, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index fa13079a74..51d19c4a77 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -15,28 +15,106 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Services; public sealed class ResilienceManagementServiceTests { private const string UserAssignedIdentityResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/testIdentity"; + private const string RecoveryJobName = "11111111-1111-1111-1111-111111111111"; [Fact] - public void GetRecoveryJobName_ReadsTypedTopLevelJobId() + public void GetRecoveryJobResourceId_UsesAbsoluteJobIdExactly() { - string result = ResilienceManagementService.GetRecoveryJobName(BinaryData.FromString(""" - {"jobId":"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryJobs/job1"} - """)); + ResourceIdentifier result = ResilienceManagementService.GetRecoveryJobResourceId(BinaryData.FromString(""" + {"jobId":"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryJobs/11111111-1111-1111-1111-111111111111"} + """), "sg1", "plan1"); - Assert.Equal("job1", result); + Assert.Equal( + $"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryJobs/{RecoveryJobName}", + result.ToString()); } [Fact] - public void GetRecoveryJobName_RejectsNestedJobId() + public void GetRecoveryJobResourceId_ResolvesBareJobIdUnderRequestedPlan() + { + ResourceIdentifier result = ResilienceManagementService.GetRecoveryJobResourceId( + BinaryData.FromString($$"""{"jobId":"{{RecoveryJobName}}"}"""), + "sg1", + "plan1"); + + Assert.Equal( + $"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryJobs/{RecoveryJobName}", + result.ToString()); + } + + [Fact] + public void TryGetRecoveryJobResourceId_ReturnsNullWhenResponseDoesNotContainJobId() + { + ResourceIdentifier? result = ResilienceManagementService.TryGetRecoveryJobResourceId( + BinaryData.FromString(""" + {"id":"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1"} + """), + "sg1", + "plan1"); + + Assert.Null(result); + } + + [Fact] + public void GetRecoveryJobResourceId_RejectsJobFromDifferentRecoveryPlan() + { + var exception = Assert.Throws(() => + ResilienceManagementService.GetRecoveryJobResourceId(BinaryData.FromString(""" + {"jobId":"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/other-plan/recoveryJobs/11111111-1111-1111-1111-111111111111"} + """), "sg1", "plan1")); + + Assert.Contains("invalid recovery job identifier", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void GetRecoveryJobResourceId_RejectsInvalidJobName() + { + var exception = Assert.Throws(() => + ResilienceManagementService.GetRecoveryJobResourceId( + BinaryData.FromString("""{"jobId":"job1"}"""), + "sg1", + "plan1")); + + Assert.Contains("invalid recovery job identifier", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("11111111111111111111111111111111")] + [InlineData("{11111111-1111-1111-1111-111111111111}")] + [InlineData("/not-an-arm-resource-id")] + [InlineData("not-json")] + public void GetRecoveryJobResourceId_RejectsMalformedProviderResponse(string jobId) + { + BinaryData response = jobId == "not-json" + ? BinaryData.FromString(jobId) + : BinaryData.FromObjectAsJson(new { jobId }); + + var exception = Assert.Throws(() => + ResilienceManagementService.GetRecoveryJobResourceId(response, "sg1", "plan1")); + + Assert.Contains("invalid recovery job identifier", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void GetRecoveryJobResourceId_RejectsNestedJobId() { var exception = Assert.Throws(() => - ResilienceManagementService.GetRecoveryJobName(BinaryData.FromString(""" + ResilienceManagementService.GetRecoveryJobResourceId(BinaryData.FromString(""" {"details":{"jobId":"wrong-job"}} - """))); + """), "sg1", "plan1")); Assert.Contains("without returning a recovery job identifier", exception.Message, StringComparison.Ordinal); } + [Fact] + public void GetRequiredProperties_RejectsMissingProperties() + { + var exception = Assert.Throws(() => + ResilienceManagementService.GetRequiredProperties(null, "recovery job")); + + Assert.Contains("recovery job without required properties", exception.Message, StringComparison.Ordinal); + } + [Fact] public async Task ExecuteWithTimeoutAsync_TimesOutOperation() { @@ -72,6 +150,17 @@ await Assert.ThrowsAnyAsync(() => cancellation.Token)); } + [Fact] + public async Task ExecuteWithTimeoutAsync_PreservesDownstreamCancellation() + { + await Assert.ThrowsAnyAsync(() => + ResilienceManagementService.ExecuteWithTimeoutAsync( + _ => Task.FromCanceled(new CancellationToken(canceled: true)), + "readiness operation", + TimeSpan.FromSeconds(1), + CancellationToken.None)); + } + [Fact] public async Task WaitForCompletionAsync_RetriesUntilCompletion() { @@ -125,6 +214,37 @@ await Assert.ThrowsAnyAsync(() => cancellation.Token)); } + [Fact] + public async Task WaitForCompletionAsync_PreservesDownstreamCancellation() + { + await Assert.ThrowsAnyAsync(() => + ResilienceManagementService.WaitForCompletionAsync( + _ => Task.FromCanceled(new CancellationToken(canceled: true)), + _ => false, + "test operation", + TimeSpan.FromMilliseconds(1), + TimeSpan.FromSeconds(1), + CancellationToken.None)); + } + + [Theory] + [InlineData("NotApplicable", true)] + [InlineData("Completed", true)] + [InlineData("CompletedWithWarnings", true)] + [InlineData("Failed", true)] + [InlineData("Skipped", true)] + [InlineData("Cancelled", true)] + [InlineData("NotStarted", false)] + [InlineData("Pending", false)] + [InlineData("InProgress", false)] + [InlineData("Cancelling", false)] + [InlineData("Paused", false)] + [InlineData("", false)] + public void IsTerminalJobStatus_ClassifiesDocumentedStatuses(string status, bool expected) + { + Assert.Equal(expected, ResilienceManagementService.IsTerminalJobStatus(status)); + } + [Fact] public void CreateRecoveryGroupsSetting_ForNewPlan_GeneratesDefaultGroupId() { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index e7f8cf8cc7..00a1d1ea0f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", - "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_ff360178ca" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_236e90f5f7" } \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 index 9a23ce7395..f14225aba4 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources-post.ps1 @@ -84,18 +84,18 @@ function Invoke-ResilienceRestPost { function Wait-ResilienceProvisioning { param( [string] $Path, - [int] $TimeoutSeconds = 900 + [int] $TimeoutSeconds = 900, + [switch] $WaitForAuthorization ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { $response = Invoke-AzRestMethod -Method GET -Path $Path - # Creation of these resources is asynchronous and eventually consistent, so a - # 404 immediately after the PUT is expected. Treat it as "not ready yet" and keep - # polling until the resource appears or we hit the timeout. - if ($response.StatusCode -eq 404) { - Write-Host " not found yet (still provisioning)" + # Resource creation is eventually consistent. Service groups also create an + # automatic administrator assignment that can take time to become effective. + if ($response.StatusCode -eq 404 -or ($WaitForAuthorization -and $response.StatusCode -eq 403)) { + Write-Host " not accessible yet (still provisioning)" Start-Sleep -Seconds 15 continue } @@ -119,6 +119,19 @@ function Wait-ResilienceProvisioning { throw "Timed out waiting for $Path to finish provisioning." } +function Add-RecoveryContributorRole { + param( + [string] $Scope + ) + + $roleName = 'Azure Resilience Management Recovery Contributor' + $assignment = Get-AzRoleAssignment -ObjectId $TestApplicationOid -Scope $Scope -RoleDefinitionName $roleName -ErrorAction SilentlyContinue + if (!$assignment) { + Write-Host "Assigning $roleName to test identity at $Scope" + New-AzRoleAssignment -ObjectId $TestApplicationOid -Scope $Scope -RoleDefinitionName $roleName | Out-Null + } +} + # 1) Create the tenant-scoped service group. $serviceGroupPath = "$serviceGroupId`?api-version=$serviceGroupApiVersion" Invoke-ResilienceRestPut -Path $serviceGroupPath -Body @{ @@ -129,7 +142,7 @@ Invoke-ResilienceRestPut -Path $serviceGroupPath -Body @{ } } } | Out-Null -Wait-ResilienceProvisioning -Path $serviceGroupPath +Wait-ResilienceProvisioning -Path $serviceGroupPath -WaitForAuthorization # Create a second enrolled service group without a recovery plan. Lifecycle tests use it # to exercise create and delete without disturbing the shared plan used by other tests. @@ -142,7 +155,10 @@ Invoke-ResilienceRestPut -Path $lifecycleServiceGroupPath -Body @{ } } } | Out-Null -Wait-ResilienceProvisioning -Path $lifecycleServiceGroupPath +Wait-ResilienceProvisioning -Path $lifecycleServiceGroupPath -WaitForAuthorization + +Add-RecoveryContributorRole -Scope $serviceGroupId +Add-RecoveryContributorRole -Scope $lifecycleServiceGroupId # 2) Add the resource group as a member of the service group so its resources # (e.g. the storage account) surface as goal/recovery resource targets. @@ -185,13 +201,27 @@ Wait-ResilienceProvisioning -Path $goalTemplatePath # 5) Assign the goal template to the service group. $goalAssignmentPath = "$serviceGroupResilienceBase/goalAssignments/$goalAssignmentName`?api-version=$resilienceApiVersion" -Invoke-ResilienceRestPut -Path $goalAssignmentPath -Body @{ - properties = @{ - goalAssignmentType = 'Resiliency' - goalTemplateId = "$serviceGroupResilienceBase/goalTemplates/$goalTemplateName" +$goalTemplateId = "$serviceGroupResilienceBase/goalTemplates/$goalTemplateName" +$existingGoalAssignment = Invoke-AzRestMethod -Method GET -Path $goalAssignmentPath +if ($existingGoalAssignment.StatusCode -eq 404) { + Invoke-ResilienceRestPut -Path $goalAssignmentPath -Body @{ + properties = @{ + goalAssignmentType = 'Resiliency' + goalTemplateId = $goalTemplateId + } + } | Out-Null + Wait-ResilienceProvisioning -Path $goalAssignmentPath +} elseif ($existingGoalAssignment.StatusCode -eq 200) { + $goalAssignment = $existingGoalAssignment.Content | ConvertFrom-Json + if ($goalAssignment.properties.goalAssignmentType -ne 'Resiliency' -or + $goalAssignment.properties.goalTemplateId -ne $goalTemplateId -or + $goalAssignment.properties.provisioningState -ne 'Succeeded') { + throw "Existing goal assignment '$goalAssignmentName' does not match the requested test configuration." } -} | Out-Null -Wait-ResilienceProvisioning -Path $goalAssignmentPath + Write-Host "Goal assignment '$goalAssignmentName' already exists with the requested configuration." +} else { + throw "GET $goalAssignmentPath failed with status $($existingGoalAssignment.StatusCode): $($existingGoalAssignment.Content)" +} # 6) Create a recovery plan on the service group. $recoveryPlanPath = "$serviceGroupResilienceBase/recoveryPlans/$recoveryPlanName`?api-version=$resilienceApiVersion" @@ -225,29 +255,47 @@ Wait-ResilienceProvisioning -Path $recoveryPlanPath # 8) Create a drill on the service group. $drillPath = "$serviceGroupResilienceBase/drills/$drillName`?api-version=$resilienceApiVersion" -Invoke-ResilienceRestPut -Path $drillPath -Body @{ - identity = @{ - type = 'SystemAssigned' - } - properties = @{ - drillType = 'Zonal' - rbacSetupMode = 'AutomatedBuiltinRoles' - drillAssetProperties = @{ - subscription = $subscriptionId - region = 'westus2' - resourceGroup = $ResourceGroupName - } - chaosResourceProperties = @{ - identity = @{ type = 'SystemAssigned' } - chaosResourceIdentityForFaults = @{ type = 'SystemAssigned' } +$recoveryPlanId = "$serviceGroupResilienceBase/recoveryPlans/$recoveryPlanName" +$existingDrill = Invoke-AzRestMethod -Method GET -Path $drillPath +if ($existingDrill.StatusCode -eq 404) { + Invoke-ResilienceRestPut -Path $drillPath -Body @{ + identity = @{ + type = 'SystemAssigned' } - recoveryPlanProperties = @{ - recoveryPlanId = "$serviceGroupResilienceBase/recoveryPlans/$recoveryPlanName" - identity = @{ type = 'SystemAssigned' } + properties = @{ + drillType = 'Zonal' + rbacSetupMode = 'AutomatedBuiltinRoles' + drillAssetProperties = @{ + subscription = $subscriptionId + region = 'westus2' + resourceGroup = $ResourceGroupName + } + chaosResourceProperties = @{ + identity = @{ type = 'SystemAssigned' } + chaosResourceIdentityForFaults = @{ type = 'SystemAssigned' } + } + recoveryPlanProperties = @{ + recoveryPlanId = $recoveryPlanId + identity = @{ type = 'SystemAssigned' } + } } + } | Out-Null + Wait-ResilienceProvisioning -Path $drillPath +} elseif ($existingDrill.StatusCode -eq 200) { + $drill = $existingDrill.Content | ConvertFrom-Json + if ($drill.properties.drillType -ne 'Zonal' -or + $drill.properties.rbacSetupMode -ne 'AutomatedBuiltinRoles' -or + $drill.properties.drillAssetProperties.subscription -ne $subscriptionId -or + $drill.properties.drillAssetProperties.region -ne 'westus2' -or + $drill.properties.drillAssetProperties.resourceGroup -ne $ResourceGroupName -or + $drill.properties.recoveryPlanProperties.recoveryPlanId -ne $recoveryPlanId -or + $drill.properties.provisioningState -ne 'Succeeded') { + throw "Existing drill '$drillName' does not match the requested test configuration." } -} | Out-Null -Wait-ResilienceProvisioning -Path $drillPath + Write-Host "Drill '$drillName' already exists with the requested configuration." +} else { + throw "GET $drillPath failed with status $($existingDrill.StatusCode): $($existingDrill.Content)" +} # Capture the drill resource created by the drill provisioning so the # drill/resource live tests can read them from deployment outputs. The drill resources appear diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep index 99487f5400..58266daa79 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/test-resources.bicep @@ -1,8 +1,10 @@ targetScope = 'resourceGroup' +param baseName string = resourceGroup().name + // Deterministic, schema-valid names. // Usage plan and enrollment names must match ^[a-zA-Z0-9-]{3,24}$. -var uniqueSuffix = uniqueString(resourceGroup().id) +var uniqueSuffix = uniqueString(resourceGroup().id, baseName) var usagePlanName = take('up${uniqueSuffix}', 24) var enrollmentName = take('en${uniqueSuffix}', 24) var serviceGroupName = 'sgr${uniqueSuffix}' From 1cedec2b258131d2914c0c242ae478861a05ead5 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Fri, 21 Aug 2026 23:04:10 +0530 Subject: [PATCH 30/38] up --- .vscode/cspell.json | 3 + .../src/Resources/consolidated-tools.json | 62 +++++++++---------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 613e671a85..64f9fde111 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -255,6 +255,7 @@ "byos", "centralised", "changedetection", + "checkreadiness", "chatbots", "chinacloudapi", "cicd", @@ -452,6 +453,8 @@ "ragzrs", "rainfly", "rdbms", + "recoveryjob", + "recoveryplan", "recoverypoint", "registeredserver", "reindexing", diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 6e8dced7d4..08cd93d398 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -923,37 +923,37 @@ ] }, { - "name": "apply_azure_advisor_recommendations", - "description": "Get rules that can help apply Advisor recommendation to create or modify IaaC files (like ARM, Bicep) for Azure resources.", - "toolMetadata": { - "destructive": { - "value": false, - "description": "This tool performs only additive updates without deleting or modifying existing resources." - }, - "idempotent": { - "value": true, - "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." - }, - "openWorld": { - "value": false, - "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." - }, - "readOnly": { - "value": true, - "description": "This tool only performs read operations without modifying any state or data." - }, - "secret": { - "value": false, - "description": "This tool does not handle sensitive or secret information." - }, - "localRequired": { - "value": false, - "description": "This tool is available in both local and remote server modes." - } - }, - "mappedToolList": [ - "advisor_recommendation_apply" - ] + "name": "apply_azure_advisor_recommendations", + "description": "Get rules that can help apply Advisor recommendation to create or modify IaaC files (like ARM, Bicep) for Azure resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "advisor_recommendation_apply" + ] }, { "name": "get_azure_retail_pricing", From c8d6ba4d30a1640fe31c6e205d3916b51091f6c4 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Sun, 23 Aug 2026 00:27:49 +0530 Subject: [PATCH 31/38] up --- .../resilience-recovery-plan-commands.yaml | 4 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 19 +- .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 3 + .../Plans/RecoveryPlanCreateCommand.cs | 285 +++++++++++++++++- .../Models/RecoveryPlanGroupActionInput.cs | 12 + .../src/Models/RecoveryPlanGroupActionKind.cs | 10 + .../src/Models/RecoveryPlanGroupInput.cs | 11 + .../Plans/RecoveryPlanCreateOption.cs | 63 +++- .../Services/IResilienceManagementService.cs | 2 +- .../Services/ResilienceManagementService.cs | 94 +++++- .../Plans/RecoveryPlanCreateCommandTests.cs | 203 +++++++++++++ .../ResilienceManagementCommandTests.cs | 13 +- .../ResilienceManagementServiceTests.cs | 91 ++++++ .../assets.json | 2 +- 14 files changed, 787 insertions(+), 25 deletions(-) create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionInput.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionKind.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInput.cs diff --git a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml index 87f9601076..94a1b26f4a 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml @@ -1,5 +1,9 @@ changes: + - section: "Features Added" + description: "Added support for creating, replacing, and removing additional recovery groups and their manual or Custom Runbook pre/post actions through the 'azmcp resilience recoveryplan create' command." - section: "Features Added" description: "Added the 'azmcp resilience recoveryplan checkreadiness' command to discover and assess whether a recovery plan and its protected resources are ready for recovery operations. The command waits for the readiness job and returns its status, errors, failed tasks, and failed resources." - section: "Breaking Changes" description: "Renamed the recovery plan command group from 'azmcp resilience recovery plan' to 'azmcp resilience recoveryplan' and the recovery job command group from 'azmcp resilience recovery job' to 'azmcp resilience recoveryjob'. Existing get commands now use the corresponding single-word resource group names." + - section: "Bugs Fixed" + description: "Aligned resilience recovery group and action input validation with the service API contract." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 26911e4d6b..08b71b3356 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3726,7 +3726,7 @@ azmcp resilience recoveryplan get --subscription \ --service-group \ [--name ] -# Create or fully update a Zonal resilience recovery plan. Ask the customer to select an identity type; do not assume SystemAssigned or another default. Identity types can switch on update, but an existing user-assigned identity cannot be replaced with a different user-assigned identity. The plan description must be 5 to 50 characters and is required on create; it is preserved when omitted on update. +# Create or fully update a Zonal resilience recovery plan. Ask the customer to select an identity type; do not assume SystemAssigned or another default. Identity types can switch on update, but an existing user-assigned identity cannot be replaced with a different user-assigned identity. The plan description must be 5 to 50 characters and is required on create; it is preserved when omitted on update. Additional groups and group actions are preserved when omitted and replaced when supplied. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recoveryplan create --service-group \ --recovery-plan \ @@ -3734,10 +3734,25 @@ azmcp resilience recoveryplan create --service-group \ [--plan-description ] \ --identity-type \ [--user-assigned-identity ] \ - [--default-group-description ] + [--default-group-description ] \ + [--default-group-pre-actions ''] \ + [--default-group-post-actions ''] \ + [--additional-groups ''] # Provide --user-assigned-identity when --identity-type is UserAssigned or SystemAndUserAssigned. # Directly replacing one user-assigned identity with another is not currently supported. +# Additional group orderId values must be unique and sequential starting at 1. groupUniqueId is optional. +# Additional group objects may contain preActions and postActions arrays. Default group actions use the dedicated options above. +# Before adding an action, collect and explain each value to the customer: +# 1. type: ManualAction pauses for a person to complete a step; CustomRunbook runs an Azure Automation runbook. +# 2. name: a non-empty customer-facing action name. +# 3. description: optional text explaining what the action does. +# 4. timeoutInMinutes: a positive whole number defining how long the action may run. +# 5. actionResourceId: required only for CustomRunbook; use the full Microsoft.Automation/automationAccounts/runbooks resource ID. +# 6. parameters: optional for CustomRunbook; use a JSON object whose values are strings. +# ManualAction example: [{"type":"ManualAction","name":"Confirm dependencies","description":"Verify dependencies are ready","timeoutInMinutes":30}] +# CustomRunbook example: [{"type":"CustomRunbook","name":"Start dependencies","description":"Start application dependencies","timeoutInMinutes":30,"actionResourceId":"/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Automation/automationAccounts/{account}/runbooks/{runbook}","parameters":{"environment":"production"}}] +# Omit an action option or property to preserve existing actions. Specify [] to clear that action list. # Delete a resilience recovery plan. Returns deleted=false when the plan does not exist. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 2560a5809d..2ef23cde9e 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -924,6 +924,9 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_recoveryplan_create | Set up a Zonal recovery plan named in service group . Use a system-assigned managed identity, description , and default recovery group description | none | | resilience_recoveryplan_create | Create Zonal recovery plan in service group and attach user-assigned managed identity . Use for the plan description and for the default recovery group | none | | resilience_recoveryplan_create | Change recovery plan in service group to a system-assigned managed identity and description . Keep its Zonal plan type and existing recovery groups | none | +| resilience_recoveryplan_create | Split recovery plan in service group into its default recovery group and one additional group described as . Preserve its existing plan type and managed identity | none | +| resilience_recoveryplan_create | Update recovery plan in service group . Add a manual pre-action named with timeout to the default group, and add a post-action script using Automation runbook to additional recovery group . Preserve its existing plan type and managed identity | none | +| resilience_recoveryplan_create | Add a pre-action to the default group of recovery plan in service group . I have not chosen the action values yet. Explain the accepted values and ask me for the action type, name, optional description, timeout, and any runbook-specific values one at a time before updating the plan. Preserve its existing plan type and managed identity | none | | resilience_recoveryplan_create | Change a system-assigned recovery plan in service group to use a user-assigned managed identity | clarification-required | | resilience_recoveryplan_create | Update recovery plan in service group to use both its system-assigned identity and user-assigned managed identity . Preserve its existing plan settings | none | | resilience_recoveryplan_checkreadiness | Check whether recovery plan and its protected resources are ready for recovery operations in service group | none | diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index bf9d7ca2d2..539864fcc6 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -2,7 +2,11 @@ // Licensed under the MIT License. using System.Net; +using System.Text; +using System.Text.Json; +using Azure.Core; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Models; using Azure.Mcp.Tools.ResilienceManagement.Options.Recovery.Plans; using Azure.Mcp.Tools.ResilienceManagement.Services; using Microsoft.Extensions.Logging; @@ -19,9 +23,12 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Creates a new Zonal resilience recovery plan in an Azure service group or fully updates an existing recovery plan. Creation requires a plan description and a customer-selected SystemAssigned, UserAssigned, or SystemAndUserAssigned managed identity. Do not assume an identity type; ask the user to choose one when omitted. Updates can switch identity - types, but cannot replace an existing user-assigned identity with a different one. Updates preserve the default recovery - group ID, additional recovery groups, and omitted plan or default group descriptions. Plan descriptions must be 5 to 50 - characters. A user-assigned identity update must include the existing identity's full resource ID; ask for it when omitted. + types, but cannot replace an existing user-assigned identity with a different one. Additional recovery groups can be + replaced with a JSON array containing sequential order IDs, descriptions, optional group GUIDs, and optional pre/post + actions; they are preserved when omitted. Default group pre/post actions can also be replaced. Actions support manual + steps and Azure Automation runbook scripts. Updates preserve the default recovery group ID and omitted plan, group, + and action settings. Plan descriptions must be 5 to 50 characters. A user-assigned identity update must include the + existing identity's full resource ID; ask for it when omitted. """, Destructive = true, Idempotent = true, @@ -32,6 +39,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; public sealed class RecoveryPlanCreateCommand(ILogger logger, IResilienceManagementService resilienceManagementService) : AuthenticatedCommand { + private const int MaxPayloadLength = 1_048_576; private readonly ILogger _logger = logger; private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService; @@ -52,9 +60,21 @@ public override void ValidateOptions(RecoveryPlanCreateOptions options, Validati validationResult.Errors.Add("The recovery plan description must be 5 to 50 characters."); } - if (options.DefaultGroupDescription is not null && options.DefaultGroupDescription.Length is < 5 or > 50) + if (options.DefaultGroupDescription is not null && + (string.IsNullOrWhiteSpace(options.DefaultGroupDescription) || options.DefaultGroupDescription.Length is < 5 or > 50)) { - validationResult.Errors.Add("The default recovery group description must be 5 to 50 characters when specified."); + validationResult.Errors.Add("The default recovery group description must be 5 to 50 characters and cannot be whitespace when specified."); + } + + try + { + _ = ParseAdditionalGroups(options.AdditionalGroups); + _ = ParseGroupActions(options.DefaultGroupPreActions, "--default-group-pre-actions"); + _ = ParseGroupActions(options.DefaultGroupPostActions, "--default-group-post-actions"); + } + catch (ArgumentException ex) + { + validationResult.Errors.Add(ex.Message); } if (options.IdentityType != Models.RecoveryPlanIdentityKind.SystemAssigned && string.IsNullOrWhiteSpace(options.UserAssignedIdentity)) @@ -82,6 +102,9 @@ public override async Task ExecuteAsync(CommandContext context, { try { + IReadOnlyList? additionalGroups = ParseAdditionalGroups(options.AdditionalGroups); + IReadOnlyList? defaultGroupPreActions = ParseGroupActions(options.DefaultGroupPreActions, "--default-group-pre-actions"); + IReadOnlyList? defaultGroupPostActions = ParseGroupActions(options.DefaultGroupPostActions, "--default-group-post-actions"); var recoveryPlan = await _resilienceManagementService.CreateRecoveryPlanAsync( options.ServiceGroup, options.RecoveryPlan, @@ -92,7 +115,10 @@ public override async Task ExecuteAsync(CommandContext context, options.DefaultGroupDescription, options.Tenant, options.RetryPolicy, - cancellationToken); + cancellationToken, + additionalGroups, + defaultGroupPreActions, + defaultGroupPostActions); context.Response.Results = ResponseResult.Create( new RecoveryPlanCreateCommandResult(recoveryPlan), @@ -109,6 +135,253 @@ public override async Task ExecuteAsync(CommandContext context, return context.Response; } + internal static IReadOnlyList? ParseAdditionalGroups(string? additionalGroupsJson) + { + if (additionalGroupsJson is null) + { + return null; + } + + if (Encoding.UTF8.GetByteCount(additionalGroupsJson) > MaxPayloadLength) + { + throw new ArgumentException("The additional recovery groups JSON payload must not exceed 1 MB."); + } + + try + { + using JsonDocument document = JsonDocument.Parse(additionalGroupsJson); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + throw new ArgumentException("--additional-groups must be a JSON array."); + } + + var groups = new List(); + var groupIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (JsonElement group in document.RootElement.EnumerateArray()) + { + if (group.ValueKind != JsonValueKind.Object || + !group.TryGetProperty("orderId", out JsonElement orderIdElement) || + !orderIdElement.TryGetInt32(out int orderId) || + !group.TryGetProperty("description", out JsonElement descriptionElement) || + descriptionElement.ValueKind != JsonValueKind.String) + { + throw new ArgumentException("Each additional recovery group must contain an integer orderId and a string description."); + } + + string description = descriptionElement.GetString()!; + if (string.IsNullOrWhiteSpace(description) || description.Length is < 5 or > 50) + { + throw new ArgumentException("Each additional recovery group description must contain 5 to 50 characters."); + } + + if (orderId is < 1 or >= 15) + { + throw new ArgumentException("Each additional recovery group orderId must be between 1 and 14."); + } + + string? groupUniqueId = null; + if (group.TryGetProperty("groupUniqueId", out JsonElement groupUniqueIdElement) && groupUniqueIdElement.ValueKind != JsonValueKind.Null) + { + if (groupUniqueIdElement.ValueKind != JsonValueKind.String || + !Guid.TryParse(groupUniqueIdElement.GetString(), out Guid parsedGroupId)) + { + throw new ArgumentException("Each additional recovery group groupUniqueId must be a GUID when specified."); + } + + groupUniqueId = parsedGroupId.ToString(); + if (!groupIds.Add(groupUniqueId)) + { + throw new ArgumentException("Additional recovery group groupUniqueId values must be unique."); + } + } + + groups.Add(new RecoveryPlanGroupInput( + groupUniqueId, + orderId, + description, + ParseGroupActionsProperty(group, "preActions"), + ParseGroupActionsProperty(group, "postActions"))); + } + + int expectedOrderId = 1; + foreach (RecoveryPlanGroupInput group in groups.OrderBy(group => group.OrderId)) + { + if (group.OrderId != expectedOrderId++) + { + throw new ArgumentException("Additional recovery group orderId values must be unique and sequential starting at 1."); + } + } + + return groups; + } + catch (JsonException ex) + { + throw new ArgumentException("--additional-groups must be a valid JSON array.", ex); + } + } + + internal static IReadOnlyList? ParseGroupActions(string? actionsJson, string optionName) + { + if (actionsJson is null) + { + return null; + } + + if (Encoding.UTF8.GetByteCount(actionsJson) > MaxPayloadLength) + { + throw new ArgumentException($"The {optionName} JSON payload must not exceed 1 MB."); + } + + try + { + using JsonDocument document = JsonDocument.Parse(actionsJson); + return ParseGroupActions(document.RootElement, optionName); + } + catch (JsonException ex) + { + throw new ArgumentException($"{optionName} must be a valid JSON array.", ex); + } + } + + private static IReadOnlyList? ParseGroupActionsProperty(JsonElement group, string propertyName) + => group.TryGetProperty(propertyName, out JsonElement actions) ? ParseGroupActions(actions, propertyName) : null; + + private static IReadOnlyList ParseGroupActions(JsonElement actions, string fieldName) + { + if (actions.ValueKind != JsonValueKind.Array) + { + throw new ArgumentException($"{fieldName} must be a JSON array."); + } + + var result = new List(); + foreach (JsonElement action in actions.EnumerateArray()) + { + if (action.ValueKind != JsonValueKind.Object || + !action.TryGetProperty("type", out JsonElement typeElement) || + typeElement.ValueKind != JsonValueKind.String || + !TryParseActionType(typeElement.GetString(), out RecoveryPlanGroupActionKind type) || + !action.TryGetProperty("name", out JsonElement nameElement) || + nameElement.ValueKind != JsonValueKind.String || + !IsValidActionName(nameElement.GetString()) || + !action.TryGetProperty("timeoutInMinutes", out JsonElement timeoutElement) || + !timeoutElement.TryGetInt32(out int timeoutInMinutes) || + timeoutInMinutes <= 0) + { + throw new ArgumentException($"Each {fieldName} action must contain type (ManualAction or CustomRunbook), a 3 to 24 character name containing only letters, numbers, or hyphens, and a positive integer timeoutInMinutes."); + } + + string? description = null; + if (action.TryGetProperty("description", out JsonElement descriptionElement) && descriptionElement.ValueKind != JsonValueKind.Null) + { + if (descriptionElement.ValueKind != JsonValueKind.String) + { + throw new ArgumentException($"Each {fieldName} action description must be a string when specified."); + } + + description = descriptionElement.GetString(); + if (description!.Length > 100) + { + throw new ArgumentException($"Each {fieldName} action description must not exceed 100 characters."); + } + } + + string? actionResourceId = null; + IReadOnlyDictionary? parameters = null; + if (type == RecoveryPlanGroupActionKind.CustomRunbook) + { + if (!action.TryGetProperty("actionResourceId", out JsonElement actionResourceIdElement) || + actionResourceIdElement.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(actionResourceIdElement.GetString())) + { + throw new ArgumentException($"Each CustomRunbook {fieldName} action requires actionResourceId."); + } + + actionResourceId = actionResourceIdElement.GetString()!; + ValidateRunbookResourceId(actionResourceId, fieldName); + parameters = ParseActionParameters(action, fieldName); + } + + result.Add(new RecoveryPlanGroupActionInput( + type, + nameElement.GetString()!, + description, + timeoutInMinutes, + actionResourceId, + parameters)); + } + + return result; + } + + private static bool IsValidActionName(string? name) + => name is { Length: >= 3 and <= 24 } && name.All(character => + character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '-'); + + private static bool TryParseActionType(string? value, out RecoveryPlanGroupActionKind type) + { + if (string.Equals(value, nameof(RecoveryPlanGroupActionKind.ManualAction), StringComparison.OrdinalIgnoreCase)) + { + type = RecoveryPlanGroupActionKind.ManualAction; + return true; + } + + if (string.Equals(value, nameof(RecoveryPlanGroupActionKind.CustomRunbook), StringComparison.OrdinalIgnoreCase)) + { + type = RecoveryPlanGroupActionKind.CustomRunbook; + return true; + } + + type = default; + return false; + } + + private static IReadOnlyDictionary? ParseActionParameters(JsonElement action, string fieldName) + { + if (!action.TryGetProperty("parameters", out JsonElement parametersElement)) + { + return null; + } + + if (parametersElement.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (parametersElement.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException($"Each CustomRunbook {fieldName} action parameters value must be a JSON object of string values."); + } + + var parameters = new Dictionary(StringComparer.Ordinal); + foreach (JsonProperty parameter in parametersElement.EnumerateObject()) + { + if (parameter.Value.ValueKind != JsonValueKind.String) + { + throw new ArgumentException($"Each CustomRunbook {fieldName} action parameter value must be a string."); + } + + parameters.Add(parameter.Name, parameter.Value.GetString()!); + } + + return parameters; + } + + private static void ValidateRunbookResourceId(string actionResourceId, string fieldName) + { + try + { + var resourceId = new ResourceIdentifier(actionResourceId); + if (!string.Equals(resourceId.ResourceType.ToString(), "Microsoft.Automation/automationAccounts/runbooks", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Each CustomRunbook {fieldName} actionResourceId must identify a Microsoft.Automation/automationAccounts/runbooks resource."); + } + } + catch (ArgumentException ex) when (!ex.Message.Contains("must identify", StringComparison.Ordinal)) + { + throw new ArgumentException($"Each CustomRunbook {fieldName} actionResourceId must be a valid Azure resource ID.", ex); + } + } + protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch { ArgumentException => HttpStatusCode.BadRequest, diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionInput.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionInput.cs new file mode 100644 index 0000000000..096b8c2f34 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionInput.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanGroupActionInput( + RecoveryPlanGroupActionKind Type, + string Name, + string? Description, + int TimeoutInMinutes, + string? ActionResourceId, + IReadOnlyDictionary? Parameters); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionKind.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionKind.cs new file mode 100644 index 0000000000..4d3c153046 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionKind.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public enum RecoveryPlanGroupActionKind +{ + ManualAction, + CustomRunbook +} \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInput.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInput.cs new file mode 100644 index 0000000000..8a64b06db0 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInput.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record RecoveryPlanGroupInput( + string? GroupUniqueId, + int OrderId, + string Description, + IReadOnlyList? PreActions = null, + IReadOnlyList? PostActions = null); \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs index 9dd5a07b94..3ec5050045 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Recovery/Plans/RecoveryPlanCreateOption.cs @@ -18,18 +18,75 @@ public sealed class RecoveryPlanCreateOptions [Option(Description = "The recovery plan type. Supported value: Zonal. The type cannot be changed after creation.")] public required RecoveryPlanKind PlanType { get; set; } - [Option(Description = "The recovery plan description, from 5 to 50 characters. Required when creating a plan; on update, the existing description is preserved when omitted.")] + [Option(Description = + "The recovery plan description, from 5 to 50 characters. " + + "Required when creating a plan; on update, the existing description is preserved when omitted.")] public string? PlanDescription { get; set; } - [Option(Description = "The customer-selected managed identity type for the recovery plan. Supported values: SystemAssigned, UserAssigned, and SystemAndUserAssigned. Do not assume a default; ask the customer when they have not specified an identity type. Specify this on every create or update; updates can switch identity types, but cannot replace an existing user-assigned identity with a different user-assigned identity.")] + [Option(Description = + "The customer-selected managed identity type for the recovery plan. " + + "Supported values: SystemAssigned, UserAssigned, and SystemAndUserAssigned. " + + "Do not assume a default; ask the customer when they have not specified an identity type. " + + "Specify this on every create or update; updates can switch identity types, but cannot replace an existing " + + "user-assigned identity with a different user-assigned identity.")] public required RecoveryPlanIdentityKind IdentityType { get; set; } - [Option(Description = "The full resource ID of the user-assigned managed identity. Required when --identity-type is UserAssigned or SystemAndUserAssigned and not allowed when it is SystemAssigned. On update, specify the existing user-assigned identity because changing it to a different user-assigned identity is not supported.")] + [Option(Description = + "The full resource ID of the user-assigned managed identity. " + + "Required when --identity-type is UserAssigned or SystemAndUserAssigned and not allowed when it is SystemAssigned. " + + "On update, specify the existing user-assigned identity because changing it to a different user-assigned identity " + + "is not supported.")] public string? UserAssignedIdentity { get; set; } [Option(Description = "The default recovery group description, from 5 to 50 characters. On update, the existing description is preserved when omitted.")] public string? DefaultGroupDescription { get; set; } + [Option(Description = + "A JSON array that replaces the default recovery group's pre-actions. " + + "Before invoking the tool, collect and explain these values to the customer one at a time: " + + "(1) type: ManualAction pauses for a person to complete a step; CustomRunbook runs an Azure Automation runbook; " + + "(2) name: a 3 to 24 character customer-facing action name containing only letters, numbers, or hyphens; " + + "(3) description: optional action instructions up to 100 characters; an empty value is allowed; " + + "(4) timeoutInMinutes: a positive whole number defining how long the action may run; " + + "(5) actionResourceId: required only for CustomRunbook and must be the full resource ID of a " + + "Microsoft.Automation/automationAccounts/runbooks resource; " + + "(6) parameters: optional or null for CustomRunbook; when provided, it must be a JSON object whose values are strings. " + + "ManualAction example: [{\"type\":\"ManualAction\",\"name\":\"Confirm-dependencies\",\"description\":\"Verify dependencies are ready\",\"timeoutInMinutes\":30}]. " + + "CustomRunbook example: [{\"type\":\"CustomRunbook\",\"name\":\"Start-dependencies\",\"description\":\"Start application dependencies\",\"timeoutInMinutes\":30,\"actionResourceId\":\"/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Automation/automationAccounts/{account}/runbooks/{runbook}\",\"parameters\":{\"environment\":\"production\"}}]. " + + "Omit this option to preserve existing pre-actions; use an empty array to clear them.")] + public string? DefaultGroupPreActions { get; set; } + + [Option(Description = + "A JSON array that replaces the default recovery group's post-actions. " + + "Before invoking the tool, collect and explain these values to the customer one at a time: " + + "(1) type: ManualAction pauses for a person to complete a step; CustomRunbook runs an Azure Automation runbook; " + + "(2) name: a 3 to 24 character customer-facing action name containing only letters, numbers, or hyphens; " + + "(3) description: optional action instructions up to 100 characters; an empty value is allowed; " + + "(4) timeoutInMinutes: a positive whole number defining how long the action may run; " + + "(5) actionResourceId: required only for CustomRunbook and must be the full resource ID of a " + + "Microsoft.Automation/automationAccounts/runbooks resource; " + + "(6) parameters: optional or null for CustomRunbook; when provided, it must be a JSON object whose values are strings. " + + "ManualAction example: [{\"type\":\"ManualAction\",\"name\":\"Confirm-recovery\",\"description\":\"Verify recovery completed successfully\",\"timeoutInMinutes\":30}]. " + + "CustomRunbook example: [{\"type\":\"CustomRunbook\",\"name\":\"Validate-recovery\",\"description\":\"Run post-recovery validation\",\"timeoutInMinutes\":30,\"actionResourceId\":\"/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Automation/automationAccounts/{account}/runbooks/{runbook}\",\"parameters\":{\"environment\":\"production\"}}]. " + + "Omit this option to preserve existing post-actions; use an empty array to clear them.")] + public string? DefaultGroupPostActions { get; set; } + + [Option(Description = + "A JSON array that replaces the additional recovery groups. " + + "Before invoking the tool, collect and explain these group values to the customer one at a time: " + + "(1) orderId: a unique whole number from 1 to 14; additional groups must be sequential starting at 1; " + + "(2) description: customer-facing text from 5 to 50 characters; " + + "(3) groupUniqueId: optional group GUID; omit it to preserve the existing group ID at that order or generate a GUID for a new group; " + + "(4) preActions and postActions: optional action arrays. For each action, collect type, a 3 to 24 character name " + + "containing only letters, numbers, or hyphens, optional action instructions up to 100 characters (empty is allowed), " + + "positive timeoutInMinutes, and, for CustomRunbook, the Automation runbook actionResourceId and optional or null string-valued parameters. " + + "ManualAction pauses for a person to complete a step; CustomRunbook runs an Azure Automation runbook. " + + "Example: [{\"orderId\":1,\"description\":\"Application recovery group\",\"preActions\":[{\"type\":\"ManualAction\",\"name\":\"Confirm-dependencies\",\"description\":\"Verify dependencies are ready\",\"timeoutInMinutes\":30}]}]. " + + "Omit preActions or postActions to preserve that action list; use an empty array to clear it. " + + "Omit this option to preserve all existing additional groups. " + + "Use an empty array to remove all additional groups.")] + public string? AdditionalGroups { get; set; } + [Option(Description = OptionDescriptions.Tenant)] public string? Tenant { get; set; } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index 8ff67f84d6..c3ef272a6d 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -36,7 +36,7 @@ public interface IResilienceManagementService Task GetRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); - Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default, IReadOnlyList? additionalGroups = null, IReadOnlyList? defaultGroupPreActions = null, IReadOnlyList? defaultGroupPostActions = null); Task DeleteRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 648112256c..6059f01323 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -399,7 +399,7 @@ public async Task GetRecoveryPlanAsync(string serviceGroup, string return document.RootElement.Clone(); } - public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) + public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default, IReadOnlyList? additionalGroups = null, IReadOnlyList? defaultGroupPreActions = null, IReadOnlyList? defaultGroupPostActions = null) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); @@ -412,7 +412,7 @@ public async Task CreateRecoveryPlanAsync(string serviceGroup, RecoveryGroupsSetting? existingRecoveryGroups = existingPlan.HasValue ? existingPlan.Value?.Data?.Properties?.RecoveryGroupsSetting : null; - RecoveryGroupsSetting recoveryGroups = CreateRecoveryGroupsSetting(existingRecoveryGroups, defaultGroupDescription); + RecoveryGroupsSetting recoveryGroups = CreateRecoveryGroupsSetting(existingRecoveryGroups, defaultGroupDescription, additionalGroups, defaultGroupPreActions, defaultGroupPostActions); ManagedServiceIdentity identity = CreateRecoveryPlanIdentity(identityType, userAssignedIdentity, existingPlan.HasValue ? existingPlan.Value?.Data?.Identity : null); var data = new RecoveryPlanData { @@ -918,10 +918,10 @@ private static void ValidateResourceType(ResourceIdentifier? resourceIdentifier, } } - internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroupsSetting? existingRecoveryGroups, string? defaultGroupDescription) + internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroupsSetting? existingRecoveryGroups, string? defaultGroupDescription, IReadOnlyList? additionalGroups = null, IReadOnlyList? defaultGroupPreActions = null, IReadOnlyList? defaultGroupPostActions = null) { RecoveryGroup? existingDefaultGroup = existingRecoveryGroups?.DefaultGroup; - RecoveryGroup defaultGroup; + RecoveryGroupsSetting recoveryGroups; if (existingDefaultGroup?.Properties is { } existingDefaultGroupProperties) { if (defaultGroupDescription is not null) @@ -929,25 +929,101 @@ internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroups existingDefaultGroupProperties.Description = defaultGroupDescription; } - return existingRecoveryGroups!; + recoveryGroups = existingRecoveryGroups!; } else { - defaultGroup = new RecoveryGroup + var defaultGroup = new RecoveryGroup { Properties = new RecoveryGroupProperties(Guid.NewGuid().ToString(), 0, defaultGroupDescription ?? "Default recovery group") }; + recoveryGroups = new RecoveryGroupsSetting(defaultGroup); } - var recoveryGroups = new RecoveryGroupsSetting(defaultGroup); - foreach (RecoveryGroup additionalGroup in existingRecoveryGroups?.AdditionalGroups ?? []) + ApplyRecoveryGroupActions(recoveryGroups.DefaultGroup.Properties!.PreActions, defaultGroupPreActions); + ApplyRecoveryGroupActions(recoveryGroups.DefaultGroup.Properties.PostActions, defaultGroupPostActions); + + if (additionalGroups is null) { - recoveryGroups.AdditionalGroups.Add(additionalGroup); + return recoveryGroups; + } + + RecoveryGroup[] existingAdditionalGroups = recoveryGroups.AdditionalGroups.ToArray(); + recoveryGroups.AdditionalGroups.Clear(); + foreach (RecoveryPlanGroupInput groupInput in additionalGroups.OrderBy(group => group.OrderId)) + { + RecoveryGroup? existingGroup = groupInput.GroupUniqueId is not null + ? existingAdditionalGroups.FirstOrDefault(group => string.Equals(group.Properties?.GroupUniqueId, groupInput.GroupUniqueId, StringComparison.OrdinalIgnoreCase)) + : existingAdditionalGroups.FirstOrDefault(group => group.Properties?.OrderId == groupInput.OrderId); + if (existingGroup?.Properties is { } existingProperties) + { + existingProperties.OrderId = groupInput.OrderId; + existingProperties.Description = groupInput.Description; + ApplyRecoveryGroupActions(existingProperties.PreActions, groupInput.PreActions); + ApplyRecoveryGroupActions(existingProperties.PostActions, groupInput.PostActions); + recoveryGroups.AdditionalGroups.Add(existingGroup); + continue; + } + + var newGroup = new RecoveryGroup + { + Properties = new RecoveryGroupProperties(groupInput.GroupUniqueId ?? Guid.NewGuid().ToString(), groupInput.OrderId, groupInput.Description) + }; + ApplyRecoveryGroupActions(newGroup.Properties.PreActions, groupInput.PreActions); + ApplyRecoveryGroupActions(newGroup.Properties.PostActions, groupInput.PostActions); + recoveryGroups.AdditionalGroups.Add(newGroup); } + ValidateRecoveryGroupIdentifiers(recoveryGroups); + return recoveryGroups; } + private static void ValidateRecoveryGroupIdentifiers(RecoveryGroupsSetting recoveryGroups) + { + string defaultGroupUniqueId = recoveryGroups.DefaultGroup.Properties!.GroupUniqueId; + if (recoveryGroups.AdditionalGroups.Any(group => + string.Equals(group.Properties?.GroupUniqueId, defaultGroupUniqueId, StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException("An additional recovery group groupUniqueId cannot match the default recovery group groupUniqueId."); + } + } + + private static void ApplyRecoveryGroupActions(IList target, IReadOnlyList? actions) + { + if (actions is null) + { + return; + } + + target.Clear(); + foreach (RecoveryPlanGroupActionInput action in actions) + { + RecoveryGroupBaseAction sdkAction = action.Type switch + { + RecoveryPlanGroupActionKind.ManualAction => new RecoveryGroupManualAction(action.Name, action.TimeoutInMinutes), + RecoveryPlanGroupActionKind.CustomRunbook => CreateCustomRunbookAction(action), + _ => throw new ArgumentOutOfRangeException(nameof(action), action.Type, "Unsupported recovery group action type.") + }; + sdkAction.Description = action.Description; + target.Add(sdkAction); + } + } + + private static RecoveryGroupCustomRunbookAction CreateCustomRunbookAction(RecoveryPlanGroupActionInput action) + { + var sdkAction = new RecoveryGroupCustomRunbookAction(action.Name, action.TimeoutInMinutes) + { + ActionResourceId = new ResourceIdentifier(action.ActionResourceId!) + }; + foreach ((string name, string value) in action.Parameters ?? new Dictionary()) + { + sdkAction.Parameters.Add(name, value); + } + + return sdkAction; + } + internal static ManagedServiceIdentity CreateRecoveryPlanIdentity(RecoveryPlanIdentityKind identityType, string? userAssignedIdentity, ManagedServiceIdentity? existingIdentity = null) { if (identityType == RecoveryPlanIdentityKind.SystemAssigned) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs index c51e88df83..f5345733b2 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs @@ -219,6 +219,7 @@ public async Task ExecuteAsync_AcceptsPlanDescriptionBoundaryLengths(string plan [Theory] [InlineData("four")] + [InlineData(" ")] [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] public async Task ExecuteAsync_RejectsDefaultGroupDescriptionOutsideAllowedLength(string defaultGroupDescription) { @@ -361,6 +362,208 @@ await Service.Received(1).CreateRecoveryPlanAsync( Arg.Any()); } + [Fact] + public async Task ExecuteAsync_ForwardsAdditionalGroups() + { + Service.CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + RecoveryPlanIdentityKind.SystemAssigned, + null, + null, + null, + null, + Arg.Any(), + Arg.Is?>(groups => + groups != null && + groups.Count == 1 && + groups[0].GroupUniqueId == null && + groups[0].OrderId == 1 && + groups[0].Description == "Second recovery group")) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--additional-groups", "[{\"orderId\":1,\"description\":\"Second recovery group\"}]"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await Service.Received(1).CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + RecoveryPlanIdentityKind.SystemAssigned, + null, + null, + null, + null, + Arg.Any(), + Arg.Is?>(groups => groups != null && groups.Count == 1)); + } + + [Theory] + [InlineData("{}", "JSON array")] + [InlineData("[{\"orderId\":2,\"description\":\"Second recovery group\"}]", "sequential starting at 1")] + [InlineData("[{\"orderId\":1,\"description\":\"four\"}]", "contain 5 to 50 characters")] + [InlineData("[{\"orderId\":1,\"description\":\" \"}]", "contain 5 to 50 characters")] + [InlineData("[{\"orderId\":15,\"description\":\"Fifteenth recovery group\"}]", "between 1 and 14")] + [InlineData("[{\"orderId\":1,\"description\":\"Second recovery group\",\"groupUniqueId\":\"not-a-guid\"}]", "must be a GUID")] + public async Task ExecuteAsync_RejectsInvalidAdditionalGroups(string additionalGroups, string expectedMessage) + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--additional-groups", additionalGroups); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains(expectedMessage, response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_ForwardsDefaultAndAdditionalGroupActions() + { + const string runbookId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Automation/automationAccounts/account/runbooks/runbook"; + Service.CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + RecoveryPlanIdentityKind.SystemAssigned, + null, + null, + null, + null, + Arg.Any(), + Arg.Is?>(groups => + groups != null && + groups[0].PreActions != null && + groups[0].PreActions![0].Type == RecoveryPlanGroupActionKind.CustomRunbook), + Arg.Is?>(actions => + actions != null && + actions[0].Type == RecoveryPlanGroupActionKind.ManualAction), + Arg.Is?>(actions => actions != null && actions.Count == 0)) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--default-group-pre-actions", "[{\"type\":\"ManualAction\",\"name\":\"Confirm-failover\",\"description\":\"Wait for approval\",\"timeoutInMinutes\":60}]", + "--default-group-post-actions", "[]", + "--additional-groups", $"[{{\"orderId\":1,\"description\":\"Second recovery group\",\"preActions\":[{{\"type\":\"CustomRunbook\",\"name\":\"Prepare-database\",\"timeoutInMinutes\":30,\"actionResourceId\":\"{runbookId}\",\"parameters\":{{\"mode\":\"safe\"}}}}]}}]"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Theory] + [InlineData("[{\"type\":\"Unknown\",\"name\":\"Action\",\"timeoutInMinutes\":10}]", "ManualAction or CustomRunbook")] + [InlineData("[{\"type\":\"1\",\"name\":\"Action\",\"timeoutInMinutes\":10}]", "ManualAction or CustomRunbook")] + [InlineData("[{\"type\":\"ManualAction\",\"name\":\"ab\",\"timeoutInMinutes\":10}]", "3 to 24 character name")] + [InlineData("[{\"type\":\"ManualAction\",\"name\":\"Invalid name\",\"timeoutInMinutes\":10}]", "only letters, numbers, or hyphens")] + [InlineData("[{\"type\":\"ManualAction\",\"name\":\"Action\",\"timeoutInMinutes\":0}]", "positive integer")] + [InlineData("[{\"type\":\"CustomRunbook\",\"name\":\"Action\",\"timeoutInMinutes\":10}]", "requires actionResourceId")] + [InlineData("[{\"type\":\"CustomRunbook\",\"name\":\"Action\",\"timeoutInMinutes\":10,\"actionResourceId\":\"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account\"}]", "automationAccounts/runbooks")] + public async Task ExecuteAsync_RejectsInvalidDefaultGroupActions(string actions, string expectedMessage) + { + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--default-group-pre-actions", actions); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains(expectedMessage, response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_AllowsEmptyActionInstructions() + { + Service.CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + RecoveryPlanIdentityKind.SystemAssigned, + null, + null, + null, + null, + Arg.Any(), + null, + Arg.Is?>(actions => actions != null && actions[0].Description == string.Empty)) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--default-group-pre-actions", "[{\"type\":\"ManualAction\",\"name\":\"Action\",\"description\":\"\",\"timeoutInMinutes\":10}]"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_AllowsNullCustomRunbookParameters() + { + const string runbookId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Automation/automationAccounts/account/runbooks/runbook"; + Service.CreateRecoveryPlanAsync( + "sg1", + "plan1", + RecoveryPlanKind.Zonal, + "description", + RecoveryPlanIdentityKind.SystemAssigned, + null, + null, + null, + null, + Arg.Any(), + null, + Arg.Is?>(actions => actions != null && actions[0].Parameters == null)) + .Returns(Element("plan1")); + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--default-group-pre-actions", $"[{{\"type\":\"CustomRunbook\",\"name\":\"Action\",\"timeoutInMinutes\":10,\"actionResourceId\":\"{runbookId}\",\"parameters\":null}}]"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + } + + [Fact] + public async Task ExecuteAsync_RejectsActionInstructionsOver100Characters() + { + string actions = $"[{{\"type\":\"ManualAction\",\"name\":\"Action\",\"description\":\"{new string('a', 101)}\",\"timeoutInMinutes\":10}}]"; + + var response = await ExecuteCommandAsync( + "--service-group", "sg1", + "--recovery-plan", "plan1", + "--plan-type", "Zonal", + "--plan-description", "description", + "--identity-type", "SystemAssigned", + "--default-group-pre-actions", actions); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("must not exceed 100 characters", response.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ExecuteAsync_HandlesConflictWithoutExposingProviderDetails() { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index 57068a26bd..7cf0e72ad8 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -249,10 +249,14 @@ public async Task Should_update_recovery_plan() var existingDefaultGroupProperties = existingRecoveryGroups .AssertProperty("defaultGroup") .AssertProperty("properties"); - string[] existingAdditionalGroups = existingRecoveryGroups + (string? GroupUniqueId, int OrderId, string? Description)[] existingAdditionalGroups = existingRecoveryGroups .AssertProperty("additionalGroups") .EnumerateArray() - .Select(group => group.GetRawText()) + .Select(group => group.AssertProperty("properties")) + .Select(properties => ( + properties.AssertProperty("groupUniqueId").GetString(), + properties.AssertProperty("orderId").GetInt32(), + properties.AssertProperty("description").GetString())) .ToArray(); var defaultGroupId = existingDefaultGroupProperties.AssertProperty("groupUniqueId").GetString(); var defaultGroupDescription = existingDefaultGroupProperties.AssertProperty("description").GetString(); @@ -279,7 +283,10 @@ public async Task Should_update_recovery_plan() Assert.Equal(defaultGroupDescription, updatedDefaultGroup.AssertProperty("description").GetString()); Assert.Equal( existingAdditionalGroups, - plan.AssertProperty("additionalGroups").EnumerateArray().Select(group => group.GetRawText())); + plan.AssertProperty("additionalGroups").EnumerateArray().Select(group => ( + group.AssertProperty("groupUniqueId").GetString(), + group.AssertProperty("orderId").GetInt32(), + group.AssertProperty("description").GetString()))); } [Fact] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index 51d19c4a77..aa9eac3798 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -292,6 +292,97 @@ public void CreateRecoveryGroupsSetting_ForUpdate_OverridesDefaultGroupDescripti Assert.Equal([postAction], result.DefaultGroup.Properties?.PostActions); } + [Fact] + public void CreateRecoveryGroupsSetting_WithAdditionalGroups_ReplacesGroupsAndPreservesIdByOrder() + { + var existingDefaultGroup = CreateGroup("7f35c9f5-bec2-455d-8161-c904b2532e5d", 0, "Existing default group"); + var existingAdditionalGroup = CreateGroup("ddcfddaf-d15d-44fe-8472-0f3ee9f0179d", 1, "Existing additional group"); + var existingGroups = new RecoveryGroupsSetting(existingDefaultGroup); + existingGroups.AdditionalGroups.Add(existingAdditionalGroup); + RecoveryPlanGroupInput[] requestedGroups = + [ + new(null, 1, "Updated additional group"), + new(null, 2, "New additional group") + ]; + + RecoveryGroupsSetting result = ResilienceManagementService.CreateRecoveryGroupsSetting(existingGroups, null, requestedGroups); + + Assert.Equal(2, result.AdditionalGroups.Count); + Assert.Same(existingAdditionalGroup, result.AdditionalGroups[0]); + Assert.Equal("ddcfddaf-d15d-44fe-8472-0f3ee9f0179d", result.AdditionalGroups[0].Properties?.GroupUniqueId); + Assert.Equal("Updated additional group", result.AdditionalGroups[0].Properties?.Description); + Assert.True(Guid.TryParse(result.AdditionalGroups[1].Properties?.GroupUniqueId, out _)); + Assert.Equal(2, result.AdditionalGroups[1].Properties?.OrderId); + } + + [Fact] + public void CreateRecoveryGroupsSetting_WithEmptyAdditionalGroups_RemovesExistingGroups() + { + var existingDefaultGroup = CreateGroup("7f35c9f5-bec2-455d-8161-c904b2532e5d", 0, "Existing default group"); + var existingGroups = new RecoveryGroupsSetting(existingDefaultGroup); + existingGroups.AdditionalGroups.Add(CreateGroup("ddcfddaf-d15d-44fe-8472-0f3ee9f0179d", 1, "Existing additional group")); + + RecoveryGroupsSetting result = ResilienceManagementService.CreateRecoveryGroupsSetting(existingGroups, null, []); + + Assert.Empty(result.AdditionalGroups); + } + + [Fact] + public void CreateRecoveryGroupsSetting_WithActions_MapsManualAndCustomRunbookActions() + { + const string runbookId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Automation/automationAccounts/account/runbooks/runbook"; + RecoveryPlanGroupActionInput[] preActions = + [ + new(RecoveryPlanGroupActionKind.ManualAction, "Confirm failover", "Wait for approval", 60, null, null) + ]; + RecoveryPlanGroupActionInput[] postActions = + [ + new(RecoveryPlanGroupActionKind.CustomRunbook, "Prepare database", "Run preparation", 30, runbookId, new Dictionary { ["mode"] = "safe" }) + ]; + + RecoveryGroupsSetting result = ResilienceManagementService.CreateRecoveryGroupsSetting(null, null, null, preActions, postActions); + + var manualAction = Assert.IsType(Assert.Single(result.DefaultGroup.Properties!.PreActions)); + Assert.Equal("Confirm failover", manualAction.Name); + Assert.Equal("Wait for approval", manualAction.Description); + Assert.Equal(60, manualAction.TimeoutInMinutes); + var runbookAction = Assert.IsType(Assert.Single(result.DefaultGroup.Properties.PostActions)); + Assert.Equal("Prepare database", runbookAction.Name); + Assert.Equal("Run preparation", runbookAction.Description); + Assert.Equal(runbookId, runbookAction.ActionResourceId.ToString()); + Assert.Equal("safe", runbookAction.Parameters["mode"]); + } + + [Fact] + public void CreateRecoveryGroupsSetting_WithOmittedActions_PreservesExistingActions() + { + var existingDefaultGroup = CreateGroup("7f35c9f5-bec2-455d-8161-c904b2532e5d", 0, "Existing default group"); + var existingAction = new RecoveryGroupManualAction("Existing action", 10); + existingDefaultGroup.Properties!.PreActions.Add(existingAction); + var existingGroups = new RecoveryGroupsSetting(existingDefaultGroup); + + RecoveryGroupsSetting result = ResilienceManagementService.CreateRecoveryGroupsSetting(existingGroups, null); + + Assert.Same(existingAction, Assert.Single(result.DefaultGroup.Properties.PreActions)); + } + + [Fact] + public void CreateRecoveryGroupsSetting_RejectsAdditionalGroupIdMatchingDefaultGroupId() + { + const string defaultGroupId = "7f35c9f5-bec2-455d-8161-c904b2532e5d"; + var existingDefaultGroup = CreateGroup(defaultGroupId, 0, "Existing default group"); + var existingGroups = new RecoveryGroupsSetting(existingDefaultGroup); + RecoveryPlanGroupInput[] additionalGroups = + [ + new(defaultGroupId, 1, "Additional recovery group", null, null) + ]; + + ArgumentException exception = Assert.Throws(() => + ResilienceManagementService.CreateRecoveryGroupsSetting(existingGroups, null, additionalGroups)); + + Assert.Contains("cannot match the default recovery group", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ResolveRecoveryPlanDescription_ForUpdate_PreservesExistingDescription() { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index 00a1d1ea0f..30e25c1e73 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", - "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_236e90f5f7" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_aa6b2dda35" } \ No newline at end of file From 8f9200075f8e76d76bce8799093588902be8d1c5 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Sun, 23 Aug 2026 01:09:15 +0530 Subject: [PATCH 32/38] up --- servers/Azure.Mcp.Server/docs/azmcp-commands.md | 4 ++-- .../Commands/ResilienceManagementJsonContext.cs | 3 +++ .../src/Models/RecoveryPlanGroupActionInput.cs | 2 +- .../src/Models/RecoveryPlanGroupActionKind.cs | 2 +- .../src/Models/RecoveryPlanGroupInput.cs | 2 +- .../src/Services/ResilienceManagementService.cs | 4 ++-- .../Services/ResilienceManagementServiceTests.cs | 15 +++++++++++++++ 7 files changed, 25 insertions(+), 7 deletions(-) diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 08b71b3356..f765703ecc 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3750,8 +3750,8 @@ azmcp resilience recoveryplan create --service-group \ # 4. timeoutInMinutes: a positive whole number defining how long the action may run. # 5. actionResourceId: required only for CustomRunbook; use the full Microsoft.Automation/automationAccounts/runbooks resource ID. # 6. parameters: optional for CustomRunbook; use a JSON object whose values are strings. -# ManualAction example: [{"type":"ManualAction","name":"Confirm dependencies","description":"Verify dependencies are ready","timeoutInMinutes":30}] -# CustomRunbook example: [{"type":"CustomRunbook","name":"Start dependencies","description":"Start application dependencies","timeoutInMinutes":30,"actionResourceId":"/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Automation/automationAccounts/{account}/runbooks/{runbook}","parameters":{"environment":"production"}}] +# ManualAction example: [{"type":"ManualAction","name":"Confirm-dependencies","description":"Verify dependencies are ready","timeoutInMinutes":30}] +# CustomRunbook example: [{"type":"CustomRunbook","name":"Start-dependencies","description":"Start application dependencies","timeoutInMinutes":30,"actionResourceId":"/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.Automation/automationAccounts/{account}/runbooks/{runbook}","parameters":{"environment":"production"}}] # Omit an action option or property to preserve existing actions. Specify [] to clear that action list. # Delete a resilience recovery plan. Returns deleted=false when the plan does not exist. diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs index 05f50d5f12..b3e6a34a7e 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs @@ -54,6 +54,9 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands; [JsonSerializable(typeof(RecoveryPlanDeleteCommand.RecoveryPlanDeleteCommandResult))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesCommand.RecoveryPlanUpdateResourcesCommandResult))] [JsonSerializable(typeof(RecoveryPlanReadinessResult))] +[JsonSerializable(typeof(RecoveryPlanReadinessError))] +[JsonSerializable(typeof(RecoveryPlanReadinessFailedTask))] +[JsonSerializable(typeof(RecoveryPlanReadinessFailedResource))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesResult))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesFailedResource))] [JsonSerializable(typeof(RecoveryPlanUpdateResourcesError))] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionInput.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionInput.cs index 096b8c2f34..7d9fa68781 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionInput.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionInput.cs @@ -9,4 +9,4 @@ public sealed record RecoveryPlanGroupActionInput( string? Description, int TimeoutInMinutes, string? ActionResourceId, - IReadOnlyDictionary? Parameters); \ No newline at end of file + IReadOnlyDictionary? Parameters); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionKind.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionKind.cs index 4d3c153046..386ecebcc6 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionKind.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupActionKind.cs @@ -7,4 +7,4 @@ public enum RecoveryPlanGroupActionKind { ManualAction, CustomRunbook -} \ No newline at end of file +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInput.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInput.cs index 8a64b06db0..6aa1971aaa 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInput.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/RecoveryPlanGroupInput.cs @@ -8,4 +8,4 @@ public sealed record RecoveryPlanGroupInput( int OrderId, string Description, IReadOnlyList? PreActions = null, - IReadOnlyList? PostActions = null); \ No newline at end of file + IReadOnlyList? PostActions = null); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 6059f01323..e7ec8e3fdd 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -757,11 +757,11 @@ internal static async Task WaitForCompletionAsync( } } - private static RecoveryPlanReadinessError? CreateReadinessError(JobErrorInfo? error) + internal static RecoveryPlanReadinessError? CreateReadinessError(JobErrorInfo? error) { return error is null ? null - : new RecoveryPlanReadinessError(error.ErrorCode, error.ErrorMessage, error.Recommendations); + : new RecoveryPlanReadinessError(error.ErrorCode, error.ErrorMessage, error.Recommendations ?? []); } private static List GetFailedTasks(IReadOnlyList? tasks) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index aa9eac3798..865e47ec0e 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -115,6 +115,21 @@ public void GetRequiredProperties_RejectsMissingProperties() Assert.Contains("recovery job without required properties", exception.Message, StringComparison.Ordinal); } + [Fact] + public void CreateReadinessError_UsesEmptyRecommendationsWhenProviderOmitsThem() + { + JobErrorInfo error = ModelReaderWriter.Read(BinaryData.FromObjectAsJson(new + { + errorCode = "NotReady", + errorMessage = "Resource requires attention." + }))!; + + RecoveryPlanReadinessError result = Assert.IsType( + ResilienceManagementService.CreateReadinessError(error)); + + Assert.Empty(result.Recommendations); + } + [Fact] public async Task ExecuteWithTimeoutAsync_TimesOutOperation() { From 56fd54544500ad070d2b9ef88c86fcba7171fb0c Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Sun, 23 Aug 2026 01:20:02 +0530 Subject: [PATCH 33/38] up --- servers/Azure.Mcp.Server/docs/azmcp-commands.md | 2 +- .../src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index f765703ecc..0a3b00c8b5 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3726,7 +3726,7 @@ azmcp resilience recoveryplan get --subscription \ --service-group \ [--name ] -# Create or fully update a Zonal resilience recovery plan. Ask the customer to select an identity type; do not assume SystemAssigned or another default. Identity types can switch on update, but an existing user-assigned identity cannot be replaced with a different user-assigned identity. The plan description must be 5 to 50 characters and is required on create; it is preserved when omitted on update. Additional groups and group actions are preserved when omitted and replaced when supplied. +# Create or update a Zonal resilience recovery plan's identity, recovery group structure, and recovery group pre/post actions. Use recoveryplan resource update instead for recovery resource membership and protection settings. Ask the customer to select an identity type; do not assume SystemAssigned or another default. Identity types can switch on update, but an existing user-assigned identity cannot be replaced with a different user-assigned identity. The plan description must be 5 to 50 characters and is required on create; it is preserved when omitted on update. Additional groups and group actions are preserved when omitted and replaced when supplied. # ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience recoveryplan create --service-group \ --recovery-plan \ diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index 539864fcc6..b50ae617da 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -20,7 +20,10 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Recovery.Plans; Name = "create", Title = "Create or Update Resilience Recovery Plan", Description = """ - Creates a new Zonal resilience recovery plan in an Azure service group or fully updates an existing recovery plan. + Creates a new Zonal resilience recovery plan in an Azure service group or updates an existing plan's identity, + recovery group structure, and recovery group pre/post actions. Use this command to split a plan into additional + recovery groups or add manual and Azure Automation runbook actions; use recoveryplan resource update instead for + recovery resource membership and protection settings. Creation requires a plan description and a customer-selected SystemAssigned, UserAssigned, or SystemAndUserAssigned managed identity. Do not assume an identity type; ask the user to choose one when omitted. Updates can switch identity types, but cannot replace an existing user-assigned identity with a different one. Additional recovery groups can be From 992dae1a21fffbe66661d1424eea8a70fdb9162b Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Mon, 24 Aug 2026 22:33:28 +0530 Subject: [PATCH 34/38] up --- .../src/Services/ResilienceManagementService.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index ef0c21cd37..577cc9654f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -940,9 +940,6 @@ internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroups recoveryGroups = new RecoveryGroupsSetting(defaultGroup); } - return error is null - ? null - : new RecoveryPlanReadinessError(error.ErrorCode, error.ErrorMessage, error.Recommendations ?? []); if (additionalGroups is null) { return recoveryGroups; From 539f5ce8018a9cb3c930218f2c9071dc05856185 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Mon, 24 Aug 2026 22:37:13 +0530 Subject: [PATCH 35/38] up --- .../changelog-entries/resilience-recovery-plan-commands.yaml | 4 +--- .../resilience-recovery-plan-update-enhancements.yaml | 5 +++++ 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-update-enhancements.yaml diff --git a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml index 3743df326b..87f9601076 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-commands.yaml @@ -1,7 +1,5 @@ changes: - section: "Features Added" - description: "Added support for creating, replacing, and removing additional recovery groups and their manual or Custom Runbook pre/post actions through the 'azmcp resilience recoveryplan create' command." + description: "Added the 'azmcp resilience recoveryplan checkreadiness' command to discover and assess whether a recovery plan and its protected resources are ready for recovery operations. The command waits for the readiness job and returns its status, errors, failed tasks, and failed resources." - section: "Breaking Changes" description: "Renamed the recovery plan command group from 'azmcp resilience recovery plan' to 'azmcp resilience recoveryplan' and the recovery job command group from 'azmcp resilience recovery job' to 'azmcp resilience recoveryjob'. Existing get commands now use the corresponding single-word resource group names." - - section: "Bugs Fixed" - description: "Aligned resilience recovery group and action input validation with the service API contract." diff --git a/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-update-enhancements.yaml b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-update-enhancements.yaml new file mode 100644 index 0000000000..912b24095c --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/resilience-recovery-plan-update-enhancements.yaml @@ -0,0 +1,5 @@ +changes: + - section: "Features Added" + description: "Added support for creating, replacing, and removing additional recovery groups and their manual or Custom Runbook pre/post actions through the 'azmcp resilience recoveryplan create' command." + - section: "Bugs Fixed" + description: "Aligned resilience recovery group and action input validation with the service API contract." From 92ea05cf3afe9046424eace2e132a1ef0809e4f5 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 25 Aug 2026 18:33:38 +0530 Subject: [PATCH 36/38] up --- .../Azure.Mcp.Server/docs/azmcp-commands.md | 4 +- .../Plans/RecoveryPlanCreateCommand.cs | 21 +++--- .../Services/IResilienceManagementService.cs | 2 +- .../Services/ResilienceManagementService.cs | 20 ++++-- .../Plans/RecoveryPlanCreateCommandTests.cs | 65 ++++++++++--------- .../ResilienceManagementCommandTests.cs | 22 ------- .../ResilienceManagementServiceTests.cs | 19 ++++++ .../assets.json | 2 +- 8 files changed, 84 insertions(+), 71 deletions(-) diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index cb2f7d9c54..82eaadb3a3 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3745,8 +3745,8 @@ azmcp resilience recoveryplan create --service-group \ # Additional group objects may contain preActions and postActions arrays. Default group actions use the dedicated options above. # Before adding an action, collect and explain each value to the customer: # 1. type: ManualAction pauses for a person to complete a step; CustomRunbook runs an Azure Automation runbook. -# 2. name: a non-empty customer-facing action name. -# 3. description: optional text explaining what the action does. +# 2. name: a 3 to 24 character customer-facing action name containing only letters, numbers, or hyphens. +# 3. description: optional action instructions up to 100 characters; an empty value is allowed. # 4. timeoutInMinutes: a positive whole number defining how long the action may run. # 5. actionResourceId: required only for CustomRunbook; use the full Microsoft.Automation/automationAccounts/runbooks resource ID. # 6. parameters: optional for CustomRunbook; use a JSON object whose values are strings. diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs index b50ae617da..477a10c908 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Recovery/Plans/RecoveryPlanCreateCommand.cs @@ -118,10 +118,10 @@ public override async Task ExecuteAsync(CommandContext context, options.DefaultGroupDescription, options.Tenant, options.RetryPolicy, - cancellationToken, - additionalGroups, - defaultGroupPreActions, - defaultGroupPostActions); + additionalGroups: additionalGroups, + defaultGroupPreActions: defaultGroupPreActions, + defaultGroupPostActions: defaultGroupPostActions, + cancellationToken: cancellationToken); context.Response.Results = ResponseResult.Create( new RecoveryPlanCreateCommandResult(recoveryPlan), @@ -371,17 +371,14 @@ private static bool TryParseActionType(string? value, out RecoveryPlanGroupActio private static void ValidateRunbookResourceId(string actionResourceId, string fieldName) { - try + if (!ResourceIdentifier.TryParse(actionResourceId, out ResourceIdentifier? resourceId) || resourceId is null) { - var resourceId = new ResourceIdentifier(actionResourceId); - if (!string.Equals(resourceId.ResourceType.ToString(), "Microsoft.Automation/automationAccounts/runbooks", StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException($"Each CustomRunbook {fieldName} actionResourceId must identify a Microsoft.Automation/automationAccounts/runbooks resource."); - } + throw new ArgumentException($"Each CustomRunbook {fieldName} actionResourceId must be a valid Azure resource ID."); } - catch (ArgumentException ex) when (!ex.Message.Contains("must identify", StringComparison.Ordinal)) + + if (!string.Equals(resourceId.ResourceType.ToString(), "Microsoft.Automation/automationAccounts/runbooks", StringComparison.OrdinalIgnoreCase)) { - throw new ArgumentException($"Each CustomRunbook {fieldName} actionResourceId must be a valid Azure resource ID.", ex); + throw new ArgumentException($"Each CustomRunbook {fieldName} actionResourceId must identify a Microsoft.Automation/automationAccounts/runbooks resource."); } } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index 16985d5777..43b1756f3f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -36,7 +36,7 @@ public interface IResilienceManagementService Task GetRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); - Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default, IReadOnlyList? additionalGroups = null, IReadOnlyList? defaultGroupPreActions = null, IReadOnlyList? defaultGroupPostActions = null); + Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, IReadOnlyList? additionalGroups = null, IReadOnlyList? defaultGroupPreActions = null, IReadOnlyList? defaultGroupPostActions = null, CancellationToken cancellationToken = default); Task DeleteRecoveryPlanAsync(string serviceGroup, string recoveryPlan, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs index 2e755854a6..6d86378577 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -399,7 +399,7 @@ public async Task GetRecoveryPlanAsync(string serviceGroup, string return document.RootElement.Clone(); } - public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default, IReadOnlyList? additionalGroups = null, IReadOnlyList? defaultGroupPreActions = null, IReadOnlyList? defaultGroupPostActions = null) + public async Task CreateRecoveryPlanAsync(string serviceGroup, string recoveryPlan, RecoveryPlanKind planType, string? planDescription, RecoveryPlanIdentityKind identityType, string? userAssignedIdentity = null, string? defaultGroupDescription = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, IReadOnlyList? additionalGroups = null, IReadOnlyList? defaultGroupPreActions = null, IReadOnlyList? defaultGroupPostActions = null, CancellationToken cancellationToken = default) { ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); @@ -944,6 +944,9 @@ internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroups recoveryGroups = new RecoveryGroupsSetting(defaultGroup); } + ApplyRecoveryGroupActions(recoveryGroups.DefaultGroup.Properties!.PreActions, defaultGroupPreActions); + ApplyRecoveryGroupActions(recoveryGroups.DefaultGroup.Properties.PostActions, defaultGroupPostActions); + if (additionalGroups is null) { return recoveryGroups; @@ -983,10 +986,19 @@ internal static RecoveryGroupsSetting CreateRecoveryGroupsSetting(RecoveryGroups private static void ValidateRecoveryGroupIdentifiers(RecoveryGroupsSetting recoveryGroups) { string defaultGroupUniqueId = recoveryGroups.DefaultGroup.Properties!.GroupUniqueId; - if (recoveryGroups.AdditionalGroups.Any(group => - string.Equals(group.Properties?.GroupUniqueId, defaultGroupUniqueId, StringComparison.OrdinalIgnoreCase))) + var additionalGroupUniqueIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (RecoveryGroup additionalGroup in recoveryGroups.AdditionalGroups) { - throw new ArgumentException("An additional recovery group groupUniqueId cannot match the default recovery group groupUniqueId."); + string? additionalGroupUniqueId = additionalGroup.Properties?.GroupUniqueId; + if (string.Equals(additionalGroupUniqueId, defaultGroupUniqueId, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("An additional recovery group groupUniqueId cannot match the default recovery group groupUniqueId."); + } + + if (additionalGroupUniqueId is not null && !additionalGroupUniqueIds.Add(additionalGroupUniqueId)) + { + throw new ArgumentException("Additional recovery group groupUniqueId values must be unique."); + } } } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs index f5345733b2..35ec3f3db2 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Recovery/Plans/RecoveryPlanCreateCommandTests.cs @@ -51,7 +51,7 @@ public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSu Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()) + cancellationToken: Arg.Any()) .Returns(Element("plan1")); } @@ -85,7 +85,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); } [Theory] @@ -115,7 +115,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); } [Fact] @@ -141,7 +141,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); } [Theory] @@ -159,7 +159,7 @@ public async Task ExecuteAsync_AcceptsRecoveryPlanNameBoundaryLengths(string rec Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()) + cancellationToken: Arg.Any()) .Returns(Element(recoveryPlan)); var response = await ExecuteCommandAsync( @@ -204,7 +204,7 @@ public async Task ExecuteAsync_AcceptsPlanDescriptionBoundaryLengths(string plan Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()) + cancellationToken: Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -250,7 +250,7 @@ public async Task ExecuteAsync_AcceptsDefaultGroupDescriptionBoundaryLengths(str defaultGroupDescription, Arg.Any(), Arg.Any(), - Arg.Any()) + cancellationToken: Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -288,7 +288,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); + cancellationToken: Arg.Any()); } [Fact] @@ -304,7 +304,7 @@ public async Task ExecuteAsync_ReturnsRecoveryPlanAndForwardsCompletePutOptions( "default", null, null, - Arg.Any()) + cancellationToken: Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync(ValidArgs); @@ -321,7 +321,7 @@ await Service.Received(1).CreateRecoveryPlanAsync( "default", null, null, - Arg.Any()); + cancellationToken: Arg.Any()); } [Fact] @@ -337,7 +337,7 @@ public async Task ExecuteAsync_ForwardsNullWhenDefaultGroupDescriptionIsOmitted( null, null, null, - Arg.Any()) + cancellationToken: Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -359,7 +359,7 @@ await Service.Received(1).CreateRecoveryPlanAsync( null, null, null, - Arg.Any()); + cancellationToken: Arg.Any()); } [Fact] @@ -375,13 +375,15 @@ public async Task ExecuteAsync_ForwardsAdditionalGroups() null, null, null, - Arg.Any(), Arg.Is?>(groups => groups != null && groups.Count == 1 && groups[0].GroupUniqueId == null && groups[0].OrderId == 1 && - groups[0].Description == "Second recovery group")) + groups[0].Description == "Second recovery group"), + null, + null, + Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -403,8 +405,10 @@ await Service.Received(1).CreateRecoveryPlanAsync( null, null, null, - Arg.Any(), - Arg.Is?>(groups => groups != null && groups.Count == 1)); + Arg.Is?>(groups => groups != null && groups.Count == 1), + null, + null, + Arg.Any()); } [Theory] @@ -442,7 +446,6 @@ public async Task ExecuteAsync_ForwardsDefaultAndAdditionalGroupActions() null, null, null, - Arg.Any(), Arg.Is?>(groups => groups != null && groups[0].PreActions != null && @@ -450,7 +453,8 @@ public async Task ExecuteAsync_ForwardsDefaultAndAdditionalGroupActions() Arg.Is?>(actions => actions != null && actions[0].Type == RecoveryPlanGroupActionKind.ManualAction), - Arg.Is?>(actions => actions != null && actions.Count == 0)) + Arg.Is?>(actions => actions != null && actions.Count == 0), + Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -473,6 +477,7 @@ public async Task ExecuteAsync_ForwardsDefaultAndAdditionalGroupActions() [InlineData("[{\"type\":\"ManualAction\",\"name\":\"Invalid name\",\"timeoutInMinutes\":10}]", "only letters, numbers, or hyphens")] [InlineData("[{\"type\":\"ManualAction\",\"name\":\"Action\",\"timeoutInMinutes\":0}]", "positive integer")] [InlineData("[{\"type\":\"CustomRunbook\",\"name\":\"Action\",\"timeoutInMinutes\":10}]", "requires actionResourceId")] + [InlineData("[{\"type\":\"CustomRunbook\",\"name\":\"Action\",\"timeoutInMinutes\":10,\"actionResourceId\":\"not-a-resource-id\"}]", "valid Azure resource ID")] [InlineData("[{\"type\":\"CustomRunbook\",\"name\":\"Action\",\"timeoutInMinutes\":10,\"actionResourceId\":\"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/account\"}]", "automationAccounts/runbooks")] public async Task ExecuteAsync_RejectsInvalidDefaultGroupActions(string actions, string expectedMessage) { @@ -501,9 +506,10 @@ public async Task ExecuteAsync_AllowsEmptyActionInstructions() null, null, null, - Arg.Any(), null, - Arg.Is?>(actions => actions != null && actions[0].Description == string.Empty)) + Arg.Is?>(actions => actions != null && actions[0].Description == string.Empty), + null, + Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -531,9 +537,10 @@ public async Task ExecuteAsync_AllowsNullCustomRunbookParameters() null, null, null, - Arg.Any(), null, - Arg.Is?>(actions => actions != null && actions[0].Parameters == null)) + Arg.Is?>(actions => actions != null && actions[0].Parameters == null), + null, + Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -599,7 +606,7 @@ await Service.DidNotReceive().CreateRecoveryPlanAsync( Arg.Any(), Arg.Any(), Arg.Any(), - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); } [Fact] @@ -615,7 +622,7 @@ public async Task ExecuteAsync_ForwardsNullForSystemAssignedIdentity() null, null, null, - Arg.Any()) + cancellationToken: Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -636,7 +643,7 @@ await Service.Received(1).CreateRecoveryPlanAsync( null, null, null, - Arg.Any()); + cancellationToken: Arg.Any()); } [Fact] @@ -652,7 +659,7 @@ public async Task ExecuteAsync_ForwardsSystemAndUserAssignedIdentity() null, null, null, - Arg.Any()) + cancellationToken: Arg.Any()) .Returns(Element("plan1")); var response = await ExecuteCommandAsync( @@ -674,7 +681,7 @@ await Service.Received(1).CreateRecoveryPlanAsync( null, null, null, - Arg.Any()); + cancellationToken: Arg.Any()); } [Theory] @@ -737,7 +744,7 @@ public async Task ExecuteAsync_HandlesServiceErrors() Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()) + cancellationToken: Arg.Any()) .ThrowsAsync(new Exception("Test error")); var response = await ExecuteCommandAsync(ValidArgs); @@ -769,7 +776,7 @@ private void ConfigureRequestFailure(HttpStatusCode status, string message) Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()) + cancellationToken: Arg.Any()) .ThrowsAsync(new RequestFailedException((int)status, message)); } } diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs index 367b3dd257..d574e9ef84 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/ResilienceManagementCommandTests.cs @@ -403,28 +403,6 @@ public async Task Should_check_recovery_plan_readiness() Assert.True(result.AssertProperty("isReady").ValueKind is JsonValueKind.True or JsonValueKind.False); } - [Fact] - [CustomMatcher(compareBody: false)] - public async Task Should_check_recovery_plan_readiness() - { - var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); - var recoveryPlan = RegisterOrRetrieveDeploymentOutputVariable("recoveryPlanName", "RECOVERYPLANNAME"); - - var result = await CallToolAsync( - "resilience_recoveryplan_checkreadiness", - new() - { - { "tenant", Settings.TenantId }, - { "service-group", serviceGroup }, - { "recovery-plan", recoveryPlan } - }); - - Assert.True(Guid.TryParse(result.AssertProperty("operationId").GetString(), out _)); - Assert.True(Guid.TryParse(result.AssertProperty("recoveryJobId").GetString(), out _)); - Assert.False(string.IsNullOrWhiteSpace(result.AssertProperty("status").GetString())); - Assert.True(result.AssertProperty("isReady").ValueKind is JsonValueKind.True or JsonValueKind.False); - } - [Fact] [CustomMatcher(compareBody: false)] public async Task Should_create_update_and_delete_recovery_plan() diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs index 865e47ec0e..b23eaaf31f 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Services/ResilienceManagementServiceTests.cs @@ -398,6 +398,25 @@ public void CreateRecoveryGroupsSetting_RejectsAdditionalGroupIdMatchingDefaultG Assert.Contains("cannot match the default recovery group", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void CreateRecoveryGroupsSetting_RejectsProvidedIdMatchingIdPreservedByOrder() + { + const string existingAdditionalGroupId = "ddcfddaf-d15d-44fe-8472-0f3ee9f0179d"; + var existingDefaultGroup = CreateGroup("7f35c9f5-bec2-455d-8161-c904b2532e5d", 0, "Existing default group"); + var existingGroups = new RecoveryGroupsSetting(existingDefaultGroup); + existingGroups.AdditionalGroups.Add(CreateGroup(existingAdditionalGroupId, 1, "Existing additional group")); + RecoveryPlanGroupInput[] additionalGroups = + [ + new(null, 1, "Preserve existing group by order", null, null), + new(existingAdditionalGroupId, 2, "Reuse existing group by ID", null, null) + ]; + + ArgumentException exception = Assert.Throws(() => + ResilienceManagementService.CreateRecoveryGroupsSetting(existingGroups, null, additionalGroups)); + + Assert.Contains("groupUniqueId values must be unique", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ResolveRecoveryPlanDescription_ForUpdate_PreservesExistingDescription() { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index 5131c0ed5e..30e25c1e73 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -3,4 +3,4 @@ "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_aa6b2dda35" -} +} \ No newline at end of file From 80d8dcf0bc7e02dbd684b30608ffe09249b48a4b Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 25 Aug 2026 18:33:54 +0530 Subject: [PATCH 37/38] up --- .../Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index 30e25c1e73..022c6c4e01 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", - "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_aa6b2dda35" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_a3f5c8c8f2" } \ No newline at end of file From 7f71e388d91b807edf8d2d16a363c932caa8ac78 Mon Sep 17 00:00:00 2001 From: Adishi Jha Date: Tue, 25 Aug 2026 18:59:51 +0530 Subject: [PATCH 38/38] up --- .../Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json index 022c6c4e01..9a253431f2 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests", - "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_a3f5c8c8f2" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_bed6f42faa" } \ No newline at end of file