Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
changes:
- section: "Features Added"
description: "Added a resilience drill resource add-or-update tool."
10 changes: 10 additions & 0 deletions servers/Azure.Mcp.Server/docs/azmcp-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -3791,6 +3791,16 @@ azmcp resilience drill resource get --service-group <service-group> \
--drill <drill> \
[--name <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 <service-group> \
--drill <drill> \
--fault-duration-minutes <fault-duration-minutes> \
[--include-resources <include-resources>] \
[--update-resources <update-resources>] \
[--exclude-resources <exclude-resources>] \
[--force-inclusion-and-update <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 <service-group> \
Expand Down
2 changes: 2 additions & 0 deletions servers/Azure.Mcp.Server/docs/e2eTestPrompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <resource_name> for resilience drill <drill_name> in service group <service_group> | none |
| resilience_drill_resource_get | Get drill target <resource_name> for resilience drill <drill_name> in service group <service_group> | none |
| resilience_drill_resource_get | Retrieve the ARM properties of drill resource <resource_name> for resilience drill <drill_name> in service group <service_group> | none |
| resilience_drill_resource_add-or-update | Add resource <resource_id> to resilience drill <drill_name> in service group <service_group> with a fault duration of <fault_duration_minutes> minutes | none |
| resilience_drill_resource_add-or-update | Update or exclude the resources of resilience drill <drill_name> in service group <service_group> | none |
| resilience_drill_run_get | List all runs of drill <drill_name> in service group <service_group> | none |
| resilience_drill_run_get | Get drill run <drill_run_name> for drill <drill_name> in service group <service_group> | none |
| resilience_drill_run_resource_get | List all resources of drill run <drill_run_name> for drill <drill_name> in service group <service_group> | none |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines 79 to +83
]
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
dynamicdhx marked this conversation as resolved.
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<DrillAddOrUpdateResourcesCommand> logger, IResilienceManagementService resilienceManagementService)
: AuthenticatedCommand<DrillAddOrUpdateResourcesOptions, DrillAddOrUpdateResourcesCommand.DrillAddOrUpdateResourcesCommandResult>
{
private const int MaxPayloadLength = 1_048_576;
private readonly ILogger<DrillAddOrUpdateResourcesCommand> _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<CommandResponse> 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<AddOrUpdateResourcesContent>)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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddSingleton<RecoveryJobResourceGetCommand>();
services.AddSingleton<DrillGetCommand>();
services.AddSingleton<DrillResourceGetCommand>();
services.AddSingleton<DrillAddOrUpdateResourcesCommand>();
services.AddSingleton<DrillRunGetCommand>();
services.AddSingleton<DrillRunResourceGetCommand>();
}
Expand Down Expand Up @@ -133,10 +134,11 @@ and high availability and disaster recovery requirements.
drills.AddCommand<DrillGetCommand>(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<DrillResourceGetCommand>(serviceProvider);
drillResources.AddCommand<DrillAddOrUpdateResourcesCommand>(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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ public interface IResilienceManagementService

Task<DrillInfo> GetDrillAsync(string serviceGroup, string drill, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);

Task<DrillAddOrUpdateResourcesResult> AddOrUpdateDrillResourcesAsync(string serviceGroup, string drill, AddOrUpdateResourcesContent content, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);

Task<IEnumerable<ResourceSummary>> ListDrillResourcesAsync(string serviceGroup, string drill, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);

Task<DrillResourceInfo> GetDrillResourceAsync(string serviceGroup, string drill, string drillResource, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,29 @@ public async Task<DrillInfo> GetDrillAsync(string serviceGroup, string drill, st
SystemData: root.TryGetProperty("systemData", out JsonElement systemDataElement) ? systemDataElement.Clone() : default);
}

public async Task<DrillAddOrUpdateResourcesResult> 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<IEnumerable<ResourceSummary>> ListDrillResourcesAsync(string serviceGroup, string drill, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken);
Expand Down
Loading
Loading