Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions servers/Azure.Mcp.Server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,7 @@ Example prompts that generate Azure CLI commands:
* "List all resilience recovery plans in service group 'my-service-group'"
* "Get the recovery plan 'my-recovery-plan' in service group 'my-service-group'"
* "List the recovery jobs of recovery plan 'my-recovery-plan' in service group 'my-service-group'"
* "Update resilience drill 'my-drill' in service group 'my-service-group' to use manual RBAC setup"
* "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'"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
changes:
- section: "Features Added"
description: "Added the resilience drill update tool for changing supporting resource placement, RBAC setup mode, and recovery plan association."
8 changes: 8 additions & 0 deletions servers/Azure.Mcp.Server/docs/azmcp-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -3753,6 +3753,14 @@ azmcp resilience drill get --subscription <subscription> \
--service-group <service-group> \
[--name <name>]

# Update mutable properties of a resilience drill
# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp resilience drill update --service-group <service-group> \
--drill <drill> \
[--subscription <subscription> --region <region>] \
[--rbac-setup-mode <AutomatedCustomRole|AutomatedBuiltinRoles|Manual>] \
[--recovery-plan <recovery-plan>]

# Get a resource (target) of a drill, or list all resources of the drill (omit --name)
# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp resilience drill resource get --subscription <subscription> \
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 @@ -902,6 +902,8 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat
|:----------|:----------|
| resilience_drill_get | List all resilience drills in service group <service_group> | none |
| resilience_drill_get | Get the details of resilience drill <drill_name> in service group <service_group> | none |
| resilience_drill_update | Update resilience drill <drill_name> in service group <service_group> to use manual RBAC setup | none |
| resilience_drill_update | Associate recovery plan <recovery_plan_name> with resilience drill <drill_name> in service group <service_group> | none |
| resilience_drill_resource_get | List all drill resources for resilience drill <drill_name> in service group <service_group> | none |
| resilience_drill_resource_get | List all drill targets for resilience drill <drill_name> in service group <service_group> | none |
| resilience_drill_resource_get | Show the resources targeted by resilience drill <drill_name> in service group <service_group> | none |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,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 or update Azure Resilience Management resources, including usage plans, usage plan enrollments, and drills, for Azure service groups.",
"toolMetadata": {
"destructive": {
"value": true,
Expand Down Expand Up @@ -73,6 +73,7 @@
}
},
"mappedToolList": [
"resilience_drill_update",
"resilience_usageplan_create",
"resilience_usageplan_enrollment_create"
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Azure.Mcp.Tools.ResilienceManagement.Options.Drills;
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.Drills;

[CommandMetadata(
Id = "9c4ad84e-7056-46ad-a857-8a81dce0c676",
Name = "update",
Title = "Update Resilience Drill",
Description = """
Updates the configuration of an existing resilience drill in an Azure service group. Use this tool to
set the drill's RBAC setup mode, associate a recovery plan with the drill, or change its supporting
resource subscription and region. Only the supplied drill configuration properties are changed.
Returns the updated drill definition and provisioning state.
""",
Destructive = true,
Idempotent = true,
OpenWorld = false,
ReadOnly = false,
Secret = false,
LocalRequired = false)]
public sealed class DrillUpdateCommand(ILogger<DrillUpdateCommand> logger, IResilienceManagementService resilienceManagementService)
: AuthenticatedCommand<DrillUpdateOptions, DrillUpdateCommand.DrillUpdateCommandResult>
{
private readonly ILogger<DrillUpdateCommand> _logger = logger;
private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService;

public override void ValidateOptions(DrillUpdateOptions options, ValidationResult validationResult)
{
base.ValidateOptions(options, validationResult);

if (string.IsNullOrWhiteSpace(options.Drill) || options.Drill.Contains('/'))
{
validationResult.Errors.Add("The drill name must be a non-empty single path segment.");
}

if (string.IsNullOrWhiteSpace(options.ServiceGroup) || options.ServiceGroup.Contains('/'))
{
validationResult.Errors.Add("The service group name must be a non-empty single path segment.");
}

if (options.RecoveryPlan is { } recoveryPlan && (string.IsNullOrWhiteSpace(recoveryPlan) || recoveryPlan.Contains('/')))
{
validationResult.Errors.Add("The recovery plan name must be a non-empty single path segment.");
}

if (options.Subscription is not null && string.IsNullOrWhiteSpace(options.Subscription))
{
validationResult.Errors.Add("The subscription must not be empty.");
}

if (options.Region is not null && string.IsNullOrWhiteSpace(options.Region))
{
validationResult.Errors.Add("The region must not be empty.");
}

if (string.IsNullOrWhiteSpace(options.Subscription) != string.IsNullOrWhiteSpace(options.Region))
{
validationResult.Errors.Add("Subscription and region must be specified together.");
}

if (string.IsNullOrWhiteSpace(options.Subscription) && options.RbacSetupMode is null && string.IsNullOrWhiteSpace(options.RecoveryPlan))
{
validationResult.Errors.Add("Specify at least one property to update: subscription and region, RBAC setup mode, or recovery plan.");
}
}

public override async Task<CommandResponse> ExecuteAsync(CommandContext context, DrillUpdateOptions options, CancellationToken cancellationToken)
{
try
{
var drill = await _resilienceManagementService.UpdateDrillAsync(
options.ServiceGroup,
options.Drill,
options.Subscription,
options.Region,
options.RbacSetupMode,
options.RecoveryPlan,
options.Tenant,
options.RetryPolicy,
cancellationToken);

context.Response.Results = ResponseResult.Create(
new DrillUpdateCommandResult(drill),
ResilienceManagementJsonContext.Default.DrillUpdateCommandResult);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error updating drill. ServiceGroup: {ServiceGroup}, Drill: {Drill}, Subscription: {Subscription}, Region: {Region}.",
options.ServiceGroup, options.Drill, options.Subscription, options.Region);
HandleException(context, ex);
}

return context.Response;
}

protected override string GetErrorMessage(Exception ex) => ex switch
{
RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict =>
"The drill could not be updated because it conflicts with the current resource state.",
RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden =>
"Authorization failed updating the drill. Verify you have the required permissions.",
RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound =>
"The drill, service group, subscription, or recovery plan was not found.",
RequestFailedException =>
"The drill update request failed. Verify the request parameters and try again.",
_ => base.GetErrorMessage(ex)
};

public sealed record DrillUpdateCommandResult(DrillInfo Drill);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands;
[JsonSerializable(typeof(DrillInfo))]
[JsonSerializable(typeof(DrillResourceInfo))]
[JsonSerializable(typeof(DrillGetCommand.DrillGetCommandResult))]
[JsonSerializable(typeof(DrillUpdateCommand.DrillUpdateCommandResult))]
[JsonSerializable(typeof(DrillResourceGetCommand.DrillResourceGetCommandResult))]
[JsonSerializable(typeof(RecoveryPlanGetCommand.RecoveryPlanGetCommandResult))]
[JsonSerializable(typeof(RecoveryResourceGetCommand.RecoveryResourceGetCommandResult))]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.Mcp.Tools.ResilienceManagement.Models;

public enum DrillRbacSetupMode
{
AutomatedCustomRole,
AutomatedBuiltinRoles,
Manual
}
Original file line number Diff line number Diff line change
@@ -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.Drills;

public sealed class DrillUpdateOptions
{
[Option(Description = ResilienceManagementOptionDescriptions.ServiceGroup)]
public required string ServiceGroup { get; set; }

[Option(Description = "The name of the resilience drill to update.")]
public required string Drill { get; set; }

[Option(Description = "The subscription ID or name where the drill's supporting resources will be created. Specify together with region.")]
public string? Subscription { get; set; }

[Option(Description = "The Azure region where the drill's supporting resources will be created. Specify together with subscription.")]
public string? Region { get; set; }

[Option(Description = "The RBAC setup mode. Supported values: AutomatedCustomRole, AutomatedBuiltinRoles, Manual.")]
public DrillRbacSetupMode? RbacSetupMode { get; set; }

[Option(Description = "The recovery plan name in the same service group to associate with the drill.")]
public string? RecoveryPlan { 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 @@ -41,6 +41,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddSingleton<RecoveryJobGetCommand>();
services.AddSingleton<RecoveryJobResourceGetCommand>();
services.AddSingleton<DrillGetCommand>();
services.AddSingleton<DrillUpdateCommand>();
services.AddSingleton<DrillResourceGetCommand>();
}

Expand Down Expand Up @@ -122,6 +123,7 @@ and high availability and disaster recovery requirements.
resilienceManagement.AddSubGroup(drills);

drills.AddCommand<DrillGetCommand>(serviceProvider);
drills.AddCommand<DrillUpdateCommand>(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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public interface IResilienceManagementService

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

Task<DrillInfo> UpdateDrillAsync(string serviceGroup, string drill, string? subscription = null, string? region = null, DrillRbacSetupMode? rbacSetupMode = null, string? recoveryPlan = null, 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 @@ -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;
Expand Down Expand Up @@ -524,6 +525,59 @@ public async Task<DrillInfo> GetDrillAsync(string serviceGroup, string drill, st
SystemData: root.TryGetProperty("systemData", out JsonElement systemDataElement) ? systemDataElement.Clone() : default);
}

public async Task<DrillInfo> UpdateDrillAsync(string serviceGroup, string drill, string? subscription = null, string? region = null, DrillRbacSetupMode? rbacSetupMode = null, string? recoveryPlan = null, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default)
{
var subscriptionId = subscription is null || AzureService.IsSubscriptionId(subscription)
? subscription
: (await AzureService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken)).Data.SubscriptionId;

ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, retryPolicy: retryPolicy, cancellationToken: cancellationToken);

var drillId = ResilienceManagementDrillResource.CreateResourceIdentifier(serviceGroup, drill);
ResilienceManagementDrillResource drillResource = armClient.GetResilienceManagementDrillResource(drillId);
var properties = new DrillUpdateProperties();
var associatedIdentity = new ResilienceManagementAssociatedIdentity(ManagedServiceIdentityType.SystemAssigned);

if (subscriptionId is not null && region is not null)
{
properties.DrillAssetProperties = new AssetPropertiesOfDrill(subscriptionId, region);
}

if (rbacSetupMode is not null)
{
properties.RbacSetupMode = new ResilienceManagementRbacSetupMode(rbacSetupMode.Value.ToString());
}

if (recoveryPlan is not null)
{
properties.RecoveryPlanProperties = ArmResilienceManagementModelFactory.RecoveryPlanPropertiesOfDrill(
associatedIdentity,
RecoveryPlanResource.CreateResourceIdentifier(serviceGroup, recoveryPlan),
recoveryPlanResourceExcludedCount: null);
}

var patch = new ResilienceManagementDrillPatch
{
Properties = properties
};

ArmOperation operation = await drillResource.UpdateAsync(WaitUntil.Started, patch, cancellationToken);
await WaitForLroCompletionAsync(operation, cancellationToken);

Response<ResilienceManagementDrillResource> response = await drillResource.GetAsync(cancellationToken);
using JsonDocument document = JsonDocument.Parse(response.GetRawResponse().Content.ToMemory());
JsonElement root = document.RootElement;

return new DrillInfo(
Id: root.TryGetProperty("id", out JsonElement idElement) ? idElement.GetString() ?? string.Empty : string.Empty,
Name: root.TryGetProperty("name", out JsonElement nameElement) ? nameElement.GetString() ?? string.Empty : string.Empty,
ResourceType: root.TryGetProperty("type", out JsonElement typeElement) ? typeElement.GetString() : null,
Location: root.TryGetProperty("location", out JsonElement locationElement) ? locationElement.GetString() : null,
Tags: GetTagsOrNull(root),
Properties: root.TryGetProperty("properties", out JsonElement propertiesElement) ? propertiesElement.Clone() : default,
SystemData: root.TryGetProperty("systemData", out JsonElement systemDataElement) ? systemDataElement.Clone() : default);
}

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