From bda02d0e2da8a1b4d4cc645a04f1b07ecc48d33a Mon Sep 17 00:00:00 2001 From: Dhruv Bharuka Date: Tue, 25 Aug 2026 15:01:59 +0530 Subject: [PATCH 1/2] initial commit --- .../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 091b47a5ca..8a5f4851dd 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_fe87600c71" + "Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_607d39e087" } From 6890313890571a8020abfb8eb625e0930b473582 Mon Sep 17 00:00:00 2001 From: Dhruv Bharuka Date: Tue, 25 Aug 2026 15:02:04 +0530 Subject: [PATCH 2/2] add drill add or update resource command --- ...vbharuka-drill-add-or-update-resources.yml | 3 + .../Azure.Mcp.Server/docs/azmcp-commands.md | 10 + .../Azure.Mcp.Server/docs/e2eTestPrompts.md | 2 + .../src/Resources/consolidated-tools.json | 3 +- .../DrillAddOrUpdateResourcesCommand.cs | 229 ++++++++++++++++++ .../ResilienceManagementJsonContext.cs | 2 + .../Models/DrillAddOrUpdateResourcesResult.cs | 6 + .../DrillAddOrUpdateResourcesOption.cs | 41 ++++ .../src/ResilienceManagementSetup.cs | 4 +- .../Services/IResilienceManagementService.cs | 2 + .../Services/ResilienceManagementService.cs | 23 ++ .../DrillAddOrUpdateResourcesCommandTests.cs | 182 ++++++++++++++ .../ResilienceManagementCommandTests.cs | 54 +++++ 13 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/dhruvbharuka-drill-add-or-update-resources.yml create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Resources/DrillAddOrUpdateResourcesCommand.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillAddOrUpdateResourcesResult.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Resources/DrillAddOrUpdateResourcesOption.cs create mode 100644 tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillAddOrUpdateResourcesCommandTests.cs diff --git a/servers/Azure.Mcp.Server/changelog-entries/dhruvbharuka-drill-add-or-update-resources.yml b/servers/Azure.Mcp.Server/changelog-entries/dhruvbharuka-drill-add-or-update-resources.yml new file mode 100644 index 0000000000..3919863fd8 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/dhruvbharuka-drill-add-or-update-resources.yml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added a resilience drill resource add-or-update tool." diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 3b332d862a..922861cb87 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -3791,6 +3791,16 @@ azmcp resilience drill resource get --service-group \ --drill \ [--name ] +# Add, update, or exclude the resources (targets) of a drill +# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp resilience drill resource add-or-update --service-group \ + --drill \ + --fault-duration-minutes \ + [--include-resources ] \ + [--update-resources ] \ + [--exclude-resources ] \ + [--force-inclusion-and-update ] + # Get a run of a drill, or list all runs of the drill (omit --name) # ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired azmcp resilience drill run get --service-group \ diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 39b66de5da..b59c3c8ee6 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -910,6 +910,8 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat | resilience_drill_resource_get | Get the complete details of drill resource for resilience drill in service group | none | | resilience_drill_resource_get | Get drill target for resilience drill in service group | none | | resilience_drill_resource_get | Retrieve the ARM properties of drill resource for resilience drill in service group | none | +| resilience_drill_resource_add-or-update | Add resource to resilience drill in service group with a fault duration of minutes | none | +| resilience_drill_resource_add-or-update | Update or exclude the resources of resilience drill in service group | none | | resilience_drill_run_get | List all runs of drill in service group | none | | resilience_drill_run_get | Get drill run for drill in service group | none | | resilience_drill_run_resource_get | List all resources of drill run for drill 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 b1b0043c33..cd4dac23a6 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -79,7 +79,8 @@ "resilience_usageplan_enrollment_create", "resilience_recoveryplan_create", "resilience_recoveryplan_delete", - "resilience_recoveryplan_resource_update" + "resilience_recoveryplan_resource_update", + "resilience_drill_resource_add-or-update" ] }, { diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Resources/DrillAddOrUpdateResourcesCommand.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Resources/DrillAddOrUpdateResourcesCommand.cs new file mode 100644 index 0000000000..b57d102ade --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/Drills/Resources/DrillAddOrUpdateResourcesCommand.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Net; +using System.Text; +using System.Text.Json; +using Azure.Mcp.Tools.ResilienceManagement.Models; +using Azure.Mcp.Tools.ResilienceManagement.Options.Drills.Resources; +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.Drills.Resources; + +[CommandMetadata( + Id = "3f0c8d4e-6b2a-4f9d-8f1c-2a7b6d1e94c5", + Name = "add-or-update", + Title = "Add or Update Resilience Drill Resources", + Description = """ + Adds a resource to a resilience drill, or updates or excludes existing drill resources, in an Azure service group. + Provide a fault duration in minutes and the Azure resource IDs to include, update, or exclude. Use this to add a + resource to a drill, change the fault settings on a drill resource, or remove a resource from the drill. It starts + the operation and returns the operation ID. + """, + Destructive = true, + Idempotent = false, + OpenWorld = false, + ReadOnly = false, + Secret = false, + LocalRequired = false)] +public sealed class DrillAddOrUpdateResourcesCommand(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(DrillAddOrUpdateResourcesOptions options, ValidationResult validationResult) + { + base.ValidateOptions(options, validationResult); + + ValidatePathSegment(options.ServiceGroup, "--service-group", validationResult); + ValidatePathSegment(options.Drill, "--drill", validationResult); + + if (options.FaultDurationMinutes <= 0) + { + validationResult.Errors.Add("--fault-duration-minutes must be greater than zero."); + } + + try + { + _ = CreateContent(options); + } + catch (ArgumentException ex) + { + validationResult.Errors.Add(ex.Message); + } + } + + public override async Task ExecuteAsync(CommandContext context, DrillAddOrUpdateResourcesOptions options, CancellationToken cancellationToken) + { + try + { + AddOrUpdateResourcesContent content = CreateContent(options); + DrillAddOrUpdateResourcesResult result = await _resilienceManagementService.AddOrUpdateDrillResourcesAsync( + options.ServiceGroup, + options.Drill, + content, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create( + new DrillAddOrUpdateResourcesCommandResult(result), + ResilienceManagementJsonContext.Default.DrillAddOrUpdateResourcesCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error adding or updating drill resources. ServiceGroup: {ServiceGroup}, Drill: {Drill}.", + options.ServiceGroup, options.Drill); + HandleException(context, ex); + } + + return context.Response; + } + + internal static AddOrUpdateResourcesContent CreateContent(DrillAddOrUpdateResourcesOptions options) + { + bool hasInclude = !string.IsNullOrWhiteSpace(options.IncludeResources); + bool hasUpdate = !string.IsNullOrWhiteSpace(options.UpdateResources); + bool hasExclude = !string.IsNullOrWhiteSpace(options.ExcludeResources); + + if (!hasInclude && !hasUpdate && !hasExclude) + { + throw new ArgumentException("Specify at least one of --include-resources, --update-resources, or --exclude-resources."); + } + + foreach (string? payload in new[] { options.IncludeResources, options.UpdateResources, options.ExcludeResources }) + { + if (payload is { } value && Encoding.UTF8.GetByteCount(value) > MaxPayloadLength) + { + throw new ArgumentException("Each drill resource JSON payload must not exceed 1 MB."); + } + } + + string? forceInclusionAndUpdate = null; + if (!string.IsNullOrWhiteSpace(options.ForceInclusionAndUpdate)) + { + if (options.ForceInclusionAndUpdate is not ("Enable" or "Disable")) + { + throw new ArgumentException("--force-inclusion-and-update must be Enable or Disable."); + } + + forceInclusionAndUpdate = options.ForceInclusionAndUpdate; + } + + try + { + using JsonDocument include = JsonDocument.Parse(options.IncludeResources ?? "[]"); + using JsonDocument update = JsonDocument.Parse(options.UpdateResources ?? "[]"); + using JsonDocument exclude = JsonDocument.Parse(options.ExcludeResources ?? "[]"); + ValidateIncludeOrUpdate(include.RootElement, "--include-resources"); + ValidateIncludeOrUpdate(update.RootElement, "--update-resources"); + ValidateExclude(exclude.RootElement); + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteNumber("faultDurationInMin", options.FaultDurationMinutes); + writer.WritePropertyName("resourceLists"); + writer.WriteStartObject(); + writer.WritePropertyName("includeResources"); + include.RootElement.WriteTo(writer); + writer.WritePropertyName("updateResources"); + update.RootElement.WriteTo(writer); + writer.WritePropertyName("excludeResources"); + exclude.RootElement.WriteTo(writer); + writer.WriteEndObject(); + if (forceInclusionAndUpdate is not null) + { + writer.WriteString("forceInclusionAndUpdate", forceInclusionAndUpdate); + } + + writer.WriteEndObject(); + } + + var reader = new Utf8JsonReader(stream.ToArray()); + var model = new AddOrUpdateResourcesContent(options.FaultDurationMinutes); + return ((IJsonModel)model).Create( + ref reader, + ModelReaderWriterOptions.Json) ?? + throw new ArgumentException("The drill resource configuration could not be parsed."); + } + catch (JsonException ex) + { + throw new ArgumentException("Drill resource inputs must be valid JSON.", ex); + } + } + + private static void ValidateIncludeOrUpdate(JsonElement resources, string optionName) + { + if (resources.ValueKind != JsonValueKind.Array) + { + throw new ArgumentException($"{optionName} must be a JSON array."); + } + + foreach (JsonElement resource in resources.EnumerateArray()) + { + if (resource.ValueKind != JsonValueKind.Object || + !resource.TryGetProperty("id", out JsonElement idElement) || + idElement.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(idElement.GetString())) + { + throw new ArgumentException($"Each resource in {optionName} must be an object with a non-empty \"id\" string."); + } + } + } + + private static void ValidateExclude(JsonElement resources) + { + if (resources.ValueKind != JsonValueKind.Array) + { + throw new ArgumentException("--exclude-resources must be a JSON array."); + } + + foreach (JsonElement resource in resources.EnumerateArray()) + { + if (resource.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(resource.GetString())) + { + throw new ArgumentException("Each value in --exclude-resources must be a non-empty Azure resource ID string."); + } + } + } + + private static void ValidatePathSegment(string value, string optionName, ValidationResult validationResult) + { + if (string.IsNullOrWhiteSpace(value) || value.Contains('/')) + { + validationResult.Errors.Add($"{optionName} must be a single non-empty path segment."); + } + } + + 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 => + "Drill resources cannot be added or updated while another drill operation is in progress.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden => + "Authorization failed adding or updating drill resources. Verify you have the required permissions.", + RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound => + "Drill not found. Verify the drill and service group exist and you have access.", + RequestFailedException => + "The drill resource add or update failed. Verify the resource IDs and fault settings, then try again.", + _ => base.GetErrorMessage(ex) + }; + + public sealed record DrillAddOrUpdateResourcesCommandResult(DrillAddOrUpdateResourcesResult Result); +} diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs index 6b02d596b9..6f9519c70d 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Commands/ResilienceManagementJsonContext.cs @@ -48,6 +48,8 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands; [JsonSerializable(typeof(DrillResourceInfo))] [JsonSerializable(typeof(DrillGetCommand.DrillGetCommandResult))] [JsonSerializable(typeof(DrillResourceGetCommand.DrillResourceGetCommandResult))] +[JsonSerializable(typeof(DrillAddOrUpdateResourcesCommand.DrillAddOrUpdateResourcesCommandResult))] +[JsonSerializable(typeof(DrillAddOrUpdateResourcesResult))] [JsonSerializable(typeof(DrillRunGetCommand.DrillRunGetCommandResult))] [JsonSerializable(typeof(DrillRunResourceGetCommand.DrillRunResourceGetCommandResult))] [JsonSerializable(typeof(RecoveryPlanGetCommand.RecoveryPlanGetCommandResult))] diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillAddOrUpdateResourcesResult.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillAddOrUpdateResourcesResult.cs new file mode 100644 index 0000000000..8c53956b82 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Models/DrillAddOrUpdateResourcesResult.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ResilienceManagement.Models; + +public sealed record DrillAddOrUpdateResourcesResult(string OperationId, bool HasCompleted); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Resources/DrillAddOrUpdateResourcesOption.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Resources/DrillAddOrUpdateResourcesOption.cs new file mode 100644 index 0000000000..c1bb7859ac --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Options/Drills/Resources/DrillAddOrUpdateResourcesOption.cs @@ -0,0 +1,41 @@ +// 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.Drills.Resources; + +public sealed class DrillAddOrUpdateResourcesOptions +{ + [Option(Description = ResilienceManagementOptionDescriptions.ServiceGroup)] + public required string ServiceGroup { get; set; } + + [Option(Description = "The name of the resilience drill whose resources will be added, updated, or excluded.")] + public required string Drill { get; set; } + + [Option(Description = "The fault duration in minutes applied to the drill resources.")] + public required int FaultDurationMinutes { get; set; } + + [Option(Description = + "A JSON array of resources to include in the drill. Each item is an object with an \"id\" (the ARM resource ID) " + + "and optional \"faultProperties\". Example: [{\"id\":\"/subscriptions/.../providers/Microsoft.Compute/virtualMachines/vm1\"}].")] + public string? IncludeResources { get; set; } + + [Option(Description = + "A JSON array of already-included drill resources to update. Each item is an object with an \"id\" (the ARM resource ID) " + + "and optional \"faultProperties\".")] + public string? UpdateResources { get; set; } + + [Option(Description = "A JSON array of ARM resource ID strings to exclude from the drill.")] + public string? ExcludeResources { get; set; } + + [Option(Description = "Whether to force inclusion and update of the resources. Allowed values: Enable, Disable.")] + public string? ForceInclusionAndUpdate { 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 cb56b1141a..b3ca80c2a9 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/ResilienceManagementSetup.cs @@ -48,6 +48,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } @@ -133,10 +134,11 @@ and high availability and disaster recovery requirements. drills.AddCommand(serviceProvider); // Create resource subgroup under drill - var drillResources = new CommandGroup("resource", "Resilience drill resource operations - Commands for listing and getting the resources (targets) of a resilience drill."); + var drillResources = new CommandGroup("resource", "Resilience drill resource operations - Commands for listing, getting, and adding or updating the resources (targets) of a resilience drill."); drills.AddSubGroup(drillResources); drillResources.AddCommand(serviceProvider); + drillResources.AddCommand(serviceProvider); // Create run subgroup under drill var drillRuns = new CommandGroup("run", "Resilience drill run operations - Commands for listing and getting the runs of a resilience drill."); diff --git a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs index a463ed0e2f..2ee45473cc 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/IResilienceManagementService.cs @@ -60,6 +60,8 @@ public interface IResilienceManagementService Task GetDrillAsync(string serviceGroup, string drill, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task AddOrUpdateDrillResourcesAsync(string serviceGroup, string drill, AddOrUpdateResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task> ListDrillResourcesAsync(string serviceGroup, string drill, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); Task GetDrillResourceAsync(string serviceGroup, string drill, string drillResource, 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 3dad0b2254..93f3023486 100644 --- a/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/src/Services/ResilienceManagementService.cs @@ -1134,6 +1134,29 @@ public async Task GetDrillAsync(string serviceGroup, string drill, st SystemData: root.TryGetProperty("systemData", out JsonElement systemDataElement) ? systemDataElement.Clone() : default); } + public async Task AddOrUpdateDrillResourcesAsync( + string serviceGroup, + string drill, + AddOrUpdateResourcesContent content, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken); + + var drillId = ResilienceManagementDrillResource.CreateResourceIdentifier(serviceGroup, drill); + ResilienceManagementDrillResource drillResource = armClient.GetResilienceManagementDrillResource(drillId); + string operationId = Guid.NewGuid().ToString(); + + var operation = await drillResource.AddOrUpdateResourcesAsync( + WaitUntil.Started, + operationId, + content, + cancellationToken); + + return new DrillAddOrUpdateResourcesResult(operationId, operation.HasCompleted); + } + public async Task> ListDrillResourcesAsync(string serviceGroup, string drill, 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/Drills/Resources/DrillAddOrUpdateResourcesCommandTests.cs b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillAddOrUpdateResourcesCommandTests.cs new file mode 100644 index 0000000000..38dddd502f --- /dev/null +++ b/tools/Azure.Mcp.Tools.ResilienceManagement/tests/Azure.Mcp.Tools.ResilienceManagement.Tests/Drills/Resources/DrillAddOrUpdateResourcesCommandTests.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Azure; +using Azure.Mcp.Tools.ResilienceManagement.Commands; +using Azure.Mcp.Tools.ResilienceManagement.Commands.Drills.Resources; +using Azure.Mcp.Tools.ResilienceManagement.Models; +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.Drills.Resources; + +public sealed class DrillAddOrUpdateResourcesCommandTests + : CommandUnitTestsBase +{ + private const string ServiceGroup = "sg1"; + private const string Drill = "drill1"; + private const string IncludeJson = "[{\"id\":\"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1\"}]"; + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = Command.GetCommand(); + + Assert.Equal("add-or-update", command.Name); + Assert.Contains("resources", command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("--drill drill1 --fault-duration-minutes 10 --include-resources " + IncludeJson, false)] + [InlineData("--service-group sg1 --fault-duration-minutes 10 --include-resources " + IncludeJson, false)] + [InlineData("--service-group sg1 --drill drill1 --include-resources " + IncludeJson, false)] + [InlineData("--service-group sg1 --drill drill1 --fault-duration-minutes 10", false)] + public async Task ExecuteAsync_ValidatesRequiredInput(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.AddOrUpdateDrillResourcesAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new DrillAddOrUpdateResourcesResult("operation1", false)); + } + + var response = await ExecuteCommandAsync(args); + + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_StartsOperationAndReturnsOperationId() + { + Service.AddOrUpdateDrillResourcesAsync( + ServiceGroup, + Drill, + Arg.Any(), + "tenant1", + Arg.Any(), + Arg.Any()) + .Returns(new DrillAddOrUpdateResourcesResult("operation1", false)); + + var response = await ExecuteCommandAsync( + "--service-group", ServiceGroup, + "--drill", Drill, + "--fault-duration-minutes", "10", + "--include-resources", IncludeJson, + "--tenant", "tenant1"); + + var result = ValidateAndDeserializeResponse( + response, + ResilienceManagementJsonContext.Default.DrillAddOrUpdateResourcesCommandResult); + Assert.Equal("operation1", result.Result.OperationId); + Assert.False(result.Result.HasCompleted); + } + + [Fact] + public async Task ExecuteAsync_RequiresAtLeastOneResourceList() + { + var response = await ExecuteCommandAsync( + "--service-group", ServiceGroup, + "--drill", Drill, + "--fault-duration-minutes", "10"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("at least one", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_RejectsNonPositiveFaultDuration() + { + var response = await ExecuteCommandAsync( + "--service-group", ServiceGroup, + "--drill", Drill, + "--fault-duration-minutes", "0", + "--include-resources", IncludeJson); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("greater than zero", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_RejectsIncludeResourceWithoutId() + { + var response = await ExecuteCommandAsync( + "--service-group", ServiceGroup, + "--drill", Drill, + "--fault-duration-minutes", "10", + "--include-resources", "[{\"faultProperties\":{}}]"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("id", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_RejectsInvalidForceValue() + { + var response = await ExecuteCommandAsync( + "--service-group", ServiceGroup, + "--drill", Drill, + "--fault-duration-minutes", "10", + "--include-resources", IncludeJson, + "--force-inclusion-and-update", "Maybe"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("Enable or Disable", response.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("--service-group", "sg/1")] + [InlineData("--drill", "drill/1")] + public async Task ExecuteAsync_RejectsInvalidPathSegments(string invalidOption, string invalidValue) + { + string serviceGroup = invalidOption == "--service-group" ? invalidValue : ServiceGroup; + string drill = invalidOption == "--drill" ? invalidValue : Drill; + + var response = await ExecuteCommandAsync( + "--service-group", serviceGroup, + "--drill", drill, + "--fault-duration-minutes", "10", + "--include-resources", IncludeJson); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("single non-empty path segment", response.Message); + } + + [Theory] + [InlineData(HttpStatusCode.Conflict, "another drill operation")] + [InlineData(HttpStatusCode.Forbidden, "Authorization failed")] + [InlineData(HttpStatusCode.NotFound, "not found")] + [InlineData(HttpStatusCode.BadRequest, "add or update failed")] + public async Task ExecuteAsync_SanitizesRequestFailedException(HttpStatusCode status, string expectedMessage) + { + const string providerDetails = "Sensitive provider details: request-id=123; endpoint=https://example.invalid"; + Service.AddOrUpdateDrillResourcesAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .ThrowsAsync(new RequestFailedException((int)status, providerDetails)); + + var response = await ExecuteCommandAsync( + "--service-group", ServiceGroup, + "--drill", Drill, + "--fault-duration-minutes", "10", + "--include-resources", IncludeJson); + + Assert.Equal(status, response.Status); + Assert.Contains(expectedMessage, response.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(providerDetails, 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 7dc89a9697..1bd203e0e9 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 @@ -588,6 +588,60 @@ public async Task Should_update_recovery_plan_resources() Assert.Equal("Excluded", inclusionState); } + [Fact] + public async Task Should_add_or_update_drill_resources() + { + var serviceGroup = RegisterOrRetrieveDeploymentOutputVariable("serviceGroupName", "SERVICEGROUPNAME"); + var drillName = RegisterOrRetrieveDeploymentOutputVariable("drillName", "DRILLNAME"); + + var listedResources = await CallToolAsync( + "resilience_drill_resource_get", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drillName } + }); + var firstResource = listedResources.AssertProperty("drillResources").EnumerateArray().First(); + var targetName = firstResource.AssertProperty("id").GetString()?.Split('/').Last(); + Assert.False(string.IsNullOrEmpty(targetName)); + + var resourceResult = await CallToolAsync( + "resilience_drill_resource_get", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drillName }, + { "name", targetName } + }); + var azureResourceId = resourceResult + .AssertProperty("drillResource") + .AssertProperty("properties") + .AssertProperty("resourceId") + .GetString(); + Assert.False(string.IsNullOrEmpty(azureResourceId)); + + var includePayload = new JsonArray + { + new JsonObject { ["id"] = azureResourceId } + }; + + var result = await CallToolAsync( + "resilience_drill_resource_add-or-update", + new() + { + { "tenant", Settings.TenantId }, + { "service-group", serviceGroup }, + { "drill", drillName }, + { "fault-duration-minutes", "10" }, + { "include-resources", includePayload.ToJsonString() } + }); + + var operationId = result.AssertProperty("result").AssertProperty("operationId").GetString(); + Assert.False(string.IsNullOrEmpty(operationId)); + } + [Fact] public async Task Should_get_recovery_job() {