Update Resilience tools to GA SDK (#3968)

This commit is contained in:
adishiritwick authored and GitHub committed 2026-10-20 10:10:54 -07:00
1 parent a70f67e85e
commit 412e187cbe
57 files changed
+3250 -894

No files matched your search

@@ -4,6 +4,28 @@
Follow [add-azure-mcp-tools](../../add-azure-mcp-tools/SKILL.md) as the canonical command, options, service, security, registration, AOT, documentation, changelog, testing, recording, and pull-request workflow. This reference contains only Resilience Management-specific requirements and overrides.
## GA SDK Compatibility
The toolset uses `Azure.ResourceManager.ResilienceManagement` 1.0.0 with API `2026-10-01`. Preview recordings must be re-recorded against GA; do not rewrite API versions or weaken matching.
The GA SDK reuses required-on-create nested models in PATCH and can serialize omitted identity/selection fields as null or empty. `DrillConfigurationPatch` preserves validated sparse configuration JSON through the SDK's protected serialization extension point. Validation requests use public constructors rather than model factories so omitted source locations do not become an empty array.
### Drills fixtures
`eng/scripts/Deploy-TestResources.ps1 -Paths ResilienceManagement` provisions everything the live tests need in one run. `test-resources-pre.ps1` creates dedicated `<group>-drills` and `<group>-drills-delete` resource groups tagged `Purpose=mcp-drills-ga-live-tests`. `test-resources.bicep` deploys the fault-injection VM fixture (`test-resources-drills.bicep`) into the drills group, and `test-resources-post.ps1` configures it through `test-resources-drills-post.ps1`. The VM stays out of the general service group so its recovery plan never discovers the fault-injection target. Drill-run resources exist only after failover, so the post script runs one complete manual failover and reprotect cycle on the dedicated VM to produce the drill-run outputs, then leaves the drill `NotRunning`. Recovery Orchestration rejects failover unless the recovery plan is `Ready`, and newly discovered service-group members move it to `Warning`; before and after the cycle the script explicitly excludes every member except the target VM, finalizes, and re-runs readiness until the plan is `Ready`. The deployment needs a region with availability zones, Chaos, and Automation capacity.
Destructive drill tests read `DRILLS*` deployment outputs; other tests keep the general outputs. Do not point a drill lifecycle test at the general service group. `remove-test-resources-pre.ps1` removes both fixtures and deletes the drills resource groups.
Creation tests must create uniquely named drills and delete them in cleanup; re-creating the shared fixture can reset its resource inclusion and readiness.
### GA Troubleshooting
- **HTTP 412 mentioning missing `properties.drillType`:** Verify backend rollout and the version that provisioned the fixture. SDK/API availability does not prove backend compatibility; older child resources may need regeneration through supported APIs. Do not substitute preview reads or fabricate the discriminator.
- **HTTP 409 or a stuck lifecycle transition:** Inspect the asynchronous operation error and drill-run state, even when the parent drill reports Ready. Allow a bounded retry only for a confirmed transient conflict. If the same VM operation conflict recurs, report a backend blocker; do not force-clear locks or repeatedly restart destructive runs.
- **HTTP 403 during fixture setup:** Verify the managed identity's role assignments and scopes, then allow for propagation. Wait for the service group itself to appear in tenant-scoped Resource Graph before creating dependent resources. Do not hide persistent authorization failures with retries.
- **Monitoring fields missing after update:** Verify persistence through an independent GET, not just PATCH completion. Check sparse serialization and explicit JSON `null` clearing. SLI tests require real service-group-scoped monitoring resources, metrics, and identity permissions; an empty workspace is insufficient.
- **Provisioning or LRO routing failures:** Check mandatory-tag policies, Automation capacity, and Chaos region support. For an approved test route, verify both backend deployment and operation-status routing. Do not weaken endpoint validation or production defaults to bypass an unsupported route.
## Skill Synchronization Gate
For every new or behaviorally changed Resilience Management tool, update this skill in the same change:
@@ -142,6 +164,7 @@ Validation must reject malformed input before constructing an SDK request. Unit
- Validate physical source locations as `<region>-az<positive-zone-number>`, for example `westus2-az1`. A normal Azure location such as `westus2` is not a physical zone selector.
- Validate cross-field rules explicitly. Drill update requires at least one change, and `subscription` and `region` must be supplied together.
- Parse ARM IDs with `ResourceIdentifier` and verify the complete expected hierarchy and resource types. For selected recovery-resource IDs, verify service group ownership, recovery-plan parentage, `recoveryResources` type, and that the leaf `parsed.Name` is a GUID. A syntactically valid ARM ID with a leaf such as `resource1` is invalid for this SDK contract.
- Drill-run failover selections use `drillResources` IDs under the requested service group and drill, not `recoveryResources` IDs. Reprotect selections remain recovery-resource IDs.
- Validate JSON with `JsonDocument`/`Utf8JsonWriter` and deserialize with `ModelReaderWriter`; do not assemble payloads by string concatenation. Enforce object/array shape, required fields, non-empty IDs, enum values, positive durations, and payload-size limits before service invocation.
- For optional selector arrays, distinguish omitted from present-but-empty according to the service contract. Require at least one selector when the operation cannot safely infer a target.
@@ -229,6 +252,8 @@ Focused command unit tests should cover:
Recorded coverage for a new action must exercise a successful service state, assert meaningful response fields, and prove any persisted state where applicable. Use list tests for collection shape and get tests for detailed shape. For create/update, assert hydrated server fields such as ID, provisioning state, drill type, RBAC mode, or recovery-plan association rather than echoing input values.
Retain GA recorded scenarios for drill creation with and without a recovery plan, Health Model configuration and sparse update/clearing, manual mark-complete/resume, and failover/reprotect with explicit selectors and service-default requests. Run lifecycle scenarios sequentially against isolated fixtures. Preserve existing goal and monitoring configuration when testing create-or-update on a prepared drill, and restore the captured monitoring baseline after testing clearing. A backend or recording blocker must be reported explicitly, not worked around by removing the scenario.
Do not weaken request-body matching to make playback pass. `[CustomMatcher(compareBody: false)]` is acceptable only when an intentionally unstable body has been investigated and response/state assertions still prove the contract.
## Tool Description Evaluation Override
@@ -314,7 +339,9 @@ Commit the updated `assets.json`. Never commit `.proxy/` or `.assets/`.
- Sanitize `operation-id` and `x-ms-operation-identifier` headers because they can contain per-run or identity data.
- Normalize the fresh `operationId` query value with a `UriRegexSanitizer`. Header sanitization alone does not prevent a playback URI mismatch, and recordings captured before URI normalization must be re-recorded.
- Register generated names, operation IDs, and job IDs as playback variables when later requests depend on the same value.
- Saved playback variables are not processed by HTTP sanitizers. Sanitize ARM-valued variables and standalone resource-group names separately, preserving valid ARM ID shapes and actual live request values.
- Preserve recording lineage when publishing a replacement asset tag. Start from the current complete asset set, add or replace the intended recordings, and run the entire Resilience Management suite in playback. Publishing a tag produced from only a focused recording run can silently omit unrelated recordings and break otherwise unchanged tests.
- Treat a test-proxy 404 stating that no recording matched as a recording, sanitizer, matcher, or asset-lineage defect. Do not remove unrelated tests or Azure functionality to make playback green.
- Use a custom matcher only for intentionally irrelevant request-body differences. Do not hide contract or payload regressions by disabling body comparison broadly.
- Inspect recordings for credentials, tenant or object identifiers, signed query values, unstable timestamps, and environment-specific resource paths before publishing.
- Inspect recordings for credentials, tenant or object identifiers, signed query values, unstable timestamps, and environment-specific resource paths before publishing.
- Authorization errors can embed caller emails and object IDs inside JSON strings. Use regex sanitizers for embedded identifiers and resource-group names in error prose; body-key sanitizers do not traverse embedded JSON. Preserve error codes and diagnostic meaning.
@@ -1,9 +1,54 @@
# Resilience Payload Reference
<!-- cspell:words reprotect reprotection runbook -->
<!-- cspell:words reprotect reprotection runbook slis -->
Use JSON strings only for parameters whose tool schema requires JSON. Do not serialize ordinary scalar or array parameters manually.
## Drill GA Configuration
`drill create` and `drill update` accept these optional JSON-string inputs:
| Option | Writable fields |
|---|---|
| `goal-assignment-properties` | `identity`. `goalAssignmentId` is read-only and is rejected. |
| `health-model-monitoring-properties` | `identity`, `healthModelId` (a full `Microsoft.CloudHealth/healthmodels` ARM ID). |
| `sli-monitoring-properties` | `identity`, `slis` (one or two selections with unique `sliId` and `type`). |
Example goal identity:
```json
{"identity":{"type":"SystemAssigned"}}
```
Example Health Model selection:
```json
{
"identity": {"type":"SystemAssigned"},
"healthModelId": "/subscriptions/{subscription}/resourceGroups/{resourceGroup}/providers/Microsoft.CloudHealth/healthmodels/{healthModel}"
}
```
Example SLI selection:
```json
{
"identity": {"type":"SystemAssigned"},
"slis": [
{
"sliId": "/providers/Microsoft.Management/serviceGroups/{serviceGroup}/providers/Microsoft.Monitor/slis/{sli}",
"type": "Availability"
}
]
}
```
On create, identity is required in each supplied configuration; Health Model and SLI configurations also require their selection. Identity includes `type` and optionally `userAssignedIdentity`; a user-assigned identity on create requires its full `Microsoft.ManagedIdentity/userAssignedIdentities` ARM ID. Supported identity types follow the ARM contract: `None`, `SystemAssigned`, `UserAssigned`, and `SystemAssigned, UserAssigned`.
On update, omitted configurations and nested fields are preserved. Supplying `slis` replaces the entire selection; allow at most one `Availability` and one `Latency` entry, with no duplicate IDs. An empty array does not clear monitoring. To clear Health Model or SLI monitoring, use the string `"null"` as that tool option's payload (CLI: `--sli-monitoring-properties null`); the resulting ARM PATCH contains JSON null. A missing/null tool parameter is omission, not a clear operation. Goal configuration cannot be cleared using null.
Each JSON option is limited to 1 MB. Unknown, duplicate, and read-only properties are rejected rather than silently discarded.
## Recovery Plan Actions
A manual action pauses the workflow for a person:
@@ -140,6 +185,20 @@ The VM must already have a discovered `AzureSiteRecovery` solution in `Protected
| `AzureSiteRecovery` | The resource is a VM with healthy ASR protection and complete reprotect/test-failover inputs | The resource is not a VM or ASR is not `Protected` |
| `AzureNative` | Never for an included resource in the current tool | Adding or re-including any resource |
## Drill Resource Inclusion and Updates
`include-resources` and `update-resources` contain objects whose `id` is the full **drill-resource ID**, not the underlying VM's ARM ID:
```json
[
{
"id": "/providers/Microsoft.Management/serviceGroups/{group}/providers/Microsoft.AzureResilienceManagement/drills/{drill}/drillResources/{resource-name}"
}
]
```
Use the drill-resource response's top-level `id`, not `properties.resourceId`. The resource must belong to the requested service group and drill. Optional `faultProperties` configures the fault. `exclude-resources` is an array of drill-resource ID strings, not objects, and each must also belong to the requested service group and drill. After an asynchronous update, refresh readiness before relying on the drill-level inclusion summary.
## Live-Test Scenario Checklist
Use the smallest fixture that reaches the state required by the command under test:
@@ -169,4 +228,4 @@ Removal is different from exclusion. Use removal only when the user wants member
Pass source locations and selected resource IDs as tool arrays, not JSON strings:
- `source-locations`: values such as `westus2` or `westus2-az3`, supplied by the user.
- `selected-resource-ids`: full recovery-resource IDs supplied or explicitly selected by the user.
- `selected-resource-ids`: full drill-resource IDs under the requested service group and drill for drill-run failover; full recovery-resource IDs for drill-run reprotect and recovery-plan operations. Use IDs supplied or explicitly selected by the user.
@@ -2,14 +2,14 @@
<!-- cspell:words reprotect reprotection -->
This reference lists all 37 tools registered under the `resiliency` namespace. Parameters marked **required** must be collected before invocation. `tenant` is optional for every tool unless the active environment requires it.
This reference lists all 41 tools registered under the `resiliency` namespace. Parameters marked **required** must be collected before invocation. `tenant` is optional for every tool unless the active environment requires it.
## Usage Plans
| Tool | Purpose | Parameters |
|---|---|---|
| `mcp_azure_mcp_ser_resiliency_usageplan_get` | List or get Azure Resilience Management usage plans; not quota, API Management, or load-testing plans | `subscription?`, `resource-group?`, `name?`, `tenant?`. A specific `name` requires `resource-group`. Omit both to list across the subscription. |
| `mcp_azure_mcp_ser_resiliency_usageplan_create` | Create or update a usage plan | **`resource-group`**, **`usage-plan`**, **`plan-type`** (`Basic` or `Standard`), `subscription?`, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_usageplan_create` | Create or update a usage plan | **`resource-group`**, **`usage-plan`**, **`plan-type`** (`Standard`), `subscription?`, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_usageplan_delete` | Delete the entire usage plan after all child enrollments are removed; returns whether the plan existed | **`resource-group`**, **`usage-plan`**, `subscription?`, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_usageplan_enrollment_get` | List or get resilience usage-plan enrollments that associate service groups; not API Management subscriptions | **`resource-group`**, **`usage-plan`**, `name?`, `subscription?`, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_usageplan_enrollment_create` | Create/update enrollment associating a service group | **`resource-group`**, **`usage-plan`**, **`enrollment`**, **`service-group`**, `subscription?`, `tenant?` |
@@ -26,24 +26,34 @@ Usage-plan and enrollment names are 3–24 characters containing letters, number
These tools are read-only. Omit `name` to list IDs and names; provide it for full details.
With API `2026-10-01`, assignment details include `requireZonalResiliency`, `serviceLevelResources`, and `errorDetails`, and member details include `resourceArmId` and `zonalResiliency`. Preview-only HA/DR goal fields are not returned. Do not infer HA/DR participation or attestation from zonal resiliency.
## Drills
Drills tools use `Azure.ResourceManager.ResilienceManagement` 1.0.0 and API `2026-10-01`. Get responses preserve returned GA fields; list responses remain ID/name summaries.
| Tool | Purpose | Parameters |
|---|---|---|
| `mcp_azure_mcp_ser_resiliency_drill_get` | List drills or get one definition | **`service-group`**, `name?`, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_create` | Create/configure a drill | **`service-group`**, **`drill`**, **`subscription`**, **`region`**, **`drill-type`** (`Zonal` or `Regional`), **`rbac-setup-mode`** (`AutomatedCustomRole`, `AutomatedBuiltinRoles`, or `Manual`), `resource-group?`, `recoveryplan?`, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_update` | Update drill RBAC, recovery plan association, or relocate supporting resources by changing subscription and region | **`service-group`**, **`drill`**, `subscription?` + `region?` together, `rbac-setup-mode?`, `recoveryplan?`, `tenant?`. Supply at least one change. |
| `mcp_azure_mcp_ser_resiliency_drill_create` | Create/configure a drill, including GA goal and monitoring properties | **`service-group`**, **`drill`**, **`subscription`**, **`region`**, **`drill-type`** (`Zonal` or `Regional`), **`rbac-setup-mode`** (`AutomatedCustomRole`, `AutomatedBuiltinRoles`, or `Manual`), `resource-group?`, `goal-assignment-properties?`, `health-model-monitoring-properties?`, `sli-monitoring-properties?`, `tenant?`. The service links the service group's recovery plan automatically; `recoveryPlanId` is read-only. |
| `mcp_azure_mcp_ser_resiliency_drill_update` | Update drill RBAC, goal identity, monitoring, or supporting-resource subscription/region | **`service-group`**, **`drill`**, `subscription?` + `region?` together, `rbac-setup-mode?`, `goal-assignment-properties?`, `health-model-monitoring-properties?`, `sli-monitoring-properties?`, `tenant?`. Supply at least one change; see JSON omission/clearing rules below. |
| `mcp_azure_mcp_ser_resiliency_drill_delete` | Permanently delete a drill definition | **`service-group`**, **`drill`**, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_start` | Start a drill execution | **`service-group`**, **`drill`**, **`mode`** (`Failover` or `TestFailover`), `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_end` | End a running drill and attest outcome | **`service-group`**, **`drill`**, **`attestation`** (`Success` or `Failed`), **`attestation-notes`**, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_check-resync-readiness` | Resync drill configuration and start a readiness check | **`service-group`**, **`drill`**, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_validate-for-execution` | Validate drill execution from physical source locations | **`service-group`**, **`drill`**, **`source-locations`**, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_validate-for-execution` | Validate drill execution with an optional operation selector | **`service-group`**, **`drill`**, `source-locations`, `operation-name?` (`Failover`, `Reprotect`, `FailoverReverse`, `ReprotectReverse`), `tenant?`. Sources may be omitted only for explicit `Reprotect`; default validation also requires sources. |
| `mcp_azure_mcp_ser_resiliency_drill_resource_get` | List drill targets or get one | **`service-group`**, **`drill`**, `name?`, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_resource_add-or-update` | Include, update, or exclude drill resources and fault settings | **`service-group`**, **`drill`**, **`fault-duration-minutes`**, `include-resources?`, `update-resources?`, `exclude-resources?`, `force-inclusion-and-update?` (`Enable` or `Disable`), `tenant?`. Supply at least one include, update, or exclude payload. |
| `mcp_azure_mcp_ser_resiliency_drill_resource_add-or-update` | Include, update, or exclude drill resources and fault settings | **`service-group`**, **`drill`**, **`fault-duration-minutes`**, `include-resources?`, `update-resources?`, `exclude-resources?`, `force-inclusion-and-update?` (`Enable` or `Disable`), `tenant?`. Supply at least one payload; include, update, and exclude IDs must identify drill resources under the requested drill, not underlying VMs. |
| `mcp_azure_mcp_ser_resiliency_drill_run_get` | List drill runs or get one | **`service-group`**, **`drill`**, `name?`, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_run_add-notes` | Add notes to an existing drill run | **`service-group`**, **`drill`**, **`drill-run`**, **`notes`**, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_run_failover` | Start failover for qualified drill-run resources | **`service-group`**, **`drill`**, **`drill-run`**, **`source-locations`**, `selected-resource-ids?` (full drill-resource IDs under this service group and drill), `auto-failover?` (default `false`), `tenant?`. |
| `mcp_azure_mcp_ser_resiliency_drill_run_resume` | Resume the drill run when its current stage permits it | **`service-group`**, **`drill`**, **`drill-run`**, `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_run_mark-complete` | Mark a drill-run stage complete and disable further retries | **`service-group`**, **`drill`**, **`drill-run`**, **`stage`** (`FaultInjection`, `Failover`, `Reprotect`, `FailoverReverse`, or `ReprotectReverse`), `tenant?` |
| `mcp_azure_mcp_ser_resiliency_drill_run_reprotect` | Start reprotection after failover when the run permits it | **`service-group`**, **`drill`**, **`drill-run`**, `selected-resource-ids?`, `tenant?`. Omit selected IDs to process all qualified resources. |
| `mcp_azure_mcp_ser_resiliency_drill_run_resource_get` | List run targets or get one | **`service-group`**, **`drill`**, **`drill-run`**, `name?`, `tenant?` |
The three configuration options accept JSON strings; see [payloads](./payloads.md#drill-ga-configuration). On create, each supplied configuration requires identity, and monitoring requires its selection. Update accepts partial objects and preserves omitted fields. Pass the string `"null"` as the Health Model or SLI option value to clear that configuration. A supplied SLI array replaces the selection; empty arrays are invalid. `goalAssignmentId` is read-only.
## Recovery Plans
| Tool | Purpose | Parameters |
@@ -33,13 +33,13 @@ The mention of a service group supplies context for resolving a usage plan; it d
1. List assignments with `mcp_azure_mcp_ser_resiliency_goal_assignment_get`.
2. For an assignment, list members with `mcp_azure_mcp_ser_resiliency_goal_resource_get`.
3. Get individual members when full HA/DR attestation, participation, or exclusion details are needed.
3. Get individual members for the GA `zonalResiliency` details. Preview-only HA/DR fields are not returned; do not substitute zonal resiliency values for them.
These tools do not create or mutate goals.
## Create and Configure a Drill
1. Collect drill type, region, supporting-resource subscription, RBAC setup mode, and optional recovery plan.
1. Collect drill type, region, supporting-resource subscription, RBAC setup mode, and optional recovery plan. If GA goal/Health Model/SLI configuration is requested, collect its identity and selected resource IDs using the payload reference; do not supply the read-only goal assignment ID.
2. Call `mcp_azure_mcp_ser_resiliency_drill_create`.
3. Get the drill and verify provisioning state.
4. List targets with `mcp_azure_mcp_ser_resiliency_drill_resource_get`.
@@ -51,7 +51,7 @@ These tools do not create or mutate goals.
1. Get the drill and confirm it is not already running.
2. Run `mcp_azure_mcp_ser_resiliency_drill_check-resync-readiness` when configuration may be stale or a current readiness result is required; retain the operation ID and do not treat acceptance as readiness.
3. Validate with `mcp_azure_mcp_ser_resiliency_drill_validate-for-execution` using the intended physical source locations.
3. Validate with `mcp_azure_mcp_ser_resiliency_drill_validate-for-execution`. Supply source locations and optionally the intended operation (`Failover`, `Reprotect`, `FailoverReverse`, or `ReprotectReverse`). Sources may be omitted only for explicit `Reprotect`; omitting the operation still requires sources.
4. Stop and report blockers when validation does not qualify the drill for execution.
5. Ask for `Failover` versus `TestFailover` if not explicit.
6. Start with `mcp_azure_mcp_ser_resiliency_drill_start` and retain the operation ID.
@@ -60,6 +60,10 @@ These tools do not create or mutate goals.
9. Use `mcp_azure_mcp_ser_resiliency_drill_run_mark-complete` only when the user explicitly wants to stop retries for the named active stage and allow the run to proceed.
10. End only a running drill with `mcp_azure_mcp_ser_resiliency_drill_end`; require outcome and notes.
For drill-run failover, always collect the physical source zones; the service does not process a failover without them. Automatic failover defaults to `false` (pause after fault injection). Failover selections must be full drill-resource IDs under the requested service group and drill, obtained with drill resource get. Reprotect can restrict processing to selected recovery-resource IDs in the same service group; omission means all qualified resources. Neither request acceptance nor successful input validation proves that fault injection or reprotection has completed.
To update monitoring, send only the fields being changed. A supplied SLI array is a replacement, not an append. Clear Health Model or SLI monitoring only on explicit request, using the string `"null"` as the corresponding JSON option payload. Omission preserves the configuration.
## Create a Recovery Plan
1. Require plan name, `Zonal` plan type, description, and identity choice.
+1 -1
View File
@@ -52,7 +52,7 @@
<PackageVersion Include="Azure.ResourceManager.RecoveryServicesBackup" Version="1.3.1" />
<PackageVersion Include="Azure.ResourceManager.Redis" Version="1.5.1" />
<PackageVersion Include="Azure.ResourceManager.RedisEnterprise" Version="1.3.0" />
<PackageVersion Include="Azure.ResourceManager.ResilienceManagement" Version="1.0.0-beta.1" />
<PackageVersion Include="Azure.ResourceManager.ResilienceManagement" Version="1.0.0" />
<PackageVersion Include="Azure.ResourceManager.ResourceGraph" Version="1.1.1" />
<PackageVersion Include="Azure.ResourceManager.ResourceHealth" Version="1.1.0" />
<PackageVersion Include="Azure.ResourceManager.Resources" Version="1.11.2" />
@@ -0,0 +1,22 @@
changes:
- section: "Bugs Fixed"
description: "Corrected drill resource include, update, and exclude inputs and examples to require drill-resource IDs rather than underlying VM IDs, and reject identifiers belonging to another drill."
- section: "Bugs Fixed"
description: "Corrected drill-run failover selections to accept full drill-resource IDs under the requested service group and drill rather than recovery-resource IDs."
- section: "Bugs Fixed"
description: "Drill-run reprotect now always sends a request body; without selected resources it sends empty reprotect properties, because the service does not process a reprotect request without a body."
- section: "Bugs Fixed"
description: "Forward drill action operation IDs in the query as well as the header for resync readiness, validation, resource add/update, start, and end, matching the existing run-action routing."
- section: "Features Added"
description: "Added GA goal-assignment identity, Health Model and SLI configuration to resiliency drill create/update, including sparse PATCH and explicit clearing. Drill validation accepts an optional operation selector and requires source locations except for explicit Reprotect. Reprotect supports selected recovery resources."
- section: "Other Changes"
description: "Updated Azure.ResourceManager.ResilienceManagement to 1.0.0 and migrated existing resiliency drill, usage-plan, recovery-plan, and recovery-job operations to the GA 2026-10-01 API."
subsection: "Dependency Updates"
- section: "Breaking Changes"
description: "Resiliency usage-plan create now accepts only the GA Standard plan type; the preview-only Basic plan type has been removed."
- section: "Breaking Changes"
description: "Removed the `--recoveryplan` option from resiliency drill create and update. The GA API treats `recoveryPlanId` as read-only, so the value was never sent; the service links the service group's recovery plan automatically."
- section: "Bugs Fixed"
description: "Resiliency drill get, create, and update responses now include the drill's managed identity, and drill resync, validation, resource add-or-update, and run mark-complete responses always include `hasCompleted`."
- section: "Breaking Changes"
description: "Resiliency goal assignment get responses now return the GA requireZonalResiliency, serviceLevelResources, and errorDetails fields, and goal resource get responses return resourceArmId and zonalResiliency. The preview-only goalAssignmentType, goalTemplateId, high-availability and disaster-recovery participation/attestation/exclusion fields, and serviceGroupMemberships have been removed."
@@ -4580,6 +4580,7 @@ azmcp resiliency recoveryjob resource get --service-group <service-group> \
[--name <name>]
# Create or update a resilience drill in a service group
# The service links the service group's recovery plan for the drill type automatically.
# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp resiliency drill create --service-group <service-group> \
--drill <drill> \
@@ -4588,7 +4589,9 @@ azmcp resiliency drill create --service-group <service-group> \
--drill-type <drill-type> \
--rbac-setup-mode <rbac-setup-mode> \
[--resource-group <resource-group>] \
[--recoveryplan <recoveryplan>]
[--goal-assignment-properties <json>] \
[--health-model-monitoring-properties <json>] \
[--sli-monitoring-properties <json>]
# Get a resilience drill, or list all drills in a service group (omit --name)
# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
@@ -4609,12 +4612,15 @@ azmcp resiliency drill end --service-group <service-group> \
--attestation-notes <attestation-notes>
# Update mutable properties of a resilience drill
# Omitted fields are preserved. Use JSON "null" to clear monitoring; SLI selections replace previous selections.
# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp resiliency drill update --service-group <service-group> \
--drill <drill> \
[--subscription <subscription> --region <region>] \
[--rbac-setup-mode <AutomatedCustomRole|AutomatedBuiltinRoles|Manual>] \
[--recoveryplan <recoveryplan>]
[--goal-assignment-properties <json>] \
[--health-model-monitoring-properties <json-or-null>] \
[--sli-monitoring-properties <json-or-null>]
# Delete a resilience drill from a service group
# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
@@ -4626,11 +4632,12 @@ azmcp resiliency drill delete --service-group <service-group> \
azmcp resiliency drill check-resync-readiness --service-group <service-group> \
--drill <drill>
# Validate whether a resilience drill is eligible for execution from the specified source locations
# Validate a resilience drill; sources may be omitted only for explicit Reprotect
# ❌ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp resiliency drill validate-for-execution --service-group <service-group> \
--drill <drill> \
--source-locations <source-locations>
[--source-locations <source-locations>] \
[--operation-name <Failover|Reprotect|FailoverReverse|ReprotectReverse>]
# Get a resource (target) of a drill, or list all resources of the drill (omit --name)
# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
@@ -4639,6 +4646,7 @@ azmcp resiliency drill resource get --service-group <service-group> \
[--name <name>]
# Add, update, or exclude the resources (targets) of a drill
# Use drill-resource IDs under .../drills/{drill}/drillResources/{name}, not underlying VM IDs.
# ✅ Destructive | ✅ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp resiliency drill resource add-or-update --service-group <service-group> \
--drill <drill> \
@@ -4661,13 +4669,13 @@ azmcp resiliency drill run add-notes --service-group <service-group> \
--drill-run <drill-run> \
--notes <notes>
# Start failover for a drill run. Repeat source locations and selected resource IDs as needed.
# Start failover for a drill run from physical source zones. Selected IDs must be full drill-resource IDs under this service group and drill.
# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp resiliency drill run failover --service-group <service-group> \
--drill <drill> \
--drill-run <drill-run> \
--source-locations <source-location> \
[--selected-resource-ids <selected-resource-id>] \
[--selected-resource-ids <drill-resource-id>] \
[--auto-failover <true|false>]
# Resume a failover drill run paused after fault injection
@@ -4683,11 +4691,12 @@ azmcp resiliency drill run mark-complete --service-group <service-group> \
--drill-run <drill-run> \
--stage <stage>
# Reprotect failed-over resources in a drill run
# Reprotect all qualified failed-over resources in a drill run, or only selected recovery-resource IDs
# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp resiliency drill run reprotect --service-group <service-group> \
--drill <drill> \
--drill-run <drill-run>
--drill-run <drill-run> \
[--selected-resource-ids <selected-resource-id>]
# Get a resource (target) of a drill run, or list all resources of the run (omit --name)
# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
@@ -1178,9 +1178,11 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat
| Tool Name | Test Prompt | Interaction |
|:----------|:----------|:----------|
| resiliency_drill_create | Create a zonal resilience drill named <drill_name> in service group <service_group> using subscription <subscription>, region <region>, resource group <resource_group>, automated built-in roles, and recovery plan <recovery_plan_name> | none |
| resiliency_drill_create | Create a zonal resilience drill named <drill_name> in service group <service_group> using subscription <subscription>, region <region>, resource group <resource_group>, and automated built-in roles | none |
| resiliency_drill_create | Create a regional resilience drill named <drill_name> in service group <service_group> using subscription <subscription>, region <region>, and manual RBAC setup | none |
| resiliency_drill_create | Create a resilience drill for service group <service_group> | clarification-required |
| resiliency_drill_create | Create a zonal resilience drill in service group <service_group> with Health Model monitoring for <health_model_id> using a system-assigned identity | clarification-required |
| resiliency_drill_create | Configure a new resilience drill with goal-assignment identity and Availability and Latency SLI monitoring | clarification-required |
| resiliency_drill_delete | Delete resilience drill <drill_name> from service group <service_group> | none |
| resiliency_drill_delete | Permanently remove drill <drill_name> in service group <service_group> | none |
| resiliency_drill_end | End resilience drill <drill_name> in service group <service_group> with a Success attestation and notes "Validation completed" | none |
@@ -1188,7 +1190,9 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat
| resiliency_drill_get | List all resilience drills in service group <service_group> | none |
| resiliency_drill_get | Get the details of resilience drill <drill_name> in service group <service_group> | none |
| resiliency_drill_update | Update resilience drill <drill_name> in service group <service_group> to use manual RBAC setup | none |
| resiliency_drill_update | Associate recovery plan <recovery_plan_name> with resilience drill <drill_name> in service group <service_group> | none |
| resiliency_drill_update | Switch resilience drill <drill_name> in service group <service_group> to the AutomatedCustomRole RBAC setup mode | none |
| resiliency_drill_update | Change the Health Model monitored by resilience drill <drill_name> in service group <service_group> to <health_model_id> without changing its identity | none |
| resiliency_drill_update | Clear SLI monitoring on resilience drill <drill_name> in service group <service_group> while preserving its other configuration | none |
| resiliency_drill_update | Move the supporting resources of resilience drill <drill_name> in service group <service_group> to subscription \<subscription> and region \<region> | none |
| resiliency_drill_resource_get | List all drill resources for resilience drill <drill_name> in service group <service_group> | none |
| resiliency_drill_resource_get | List all drill targets for resilience drill <drill_name> in service group <service_group> | none |
@@ -1200,6 +1204,8 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat
| resiliency_drill_check-resync-readiness | Run a resync readiness check for resilience drill <drill_name> in service group <service_group> | none |
| resiliency_drill_validate-for-execution | Validate resilience drill <drill_name> in service group <service_group> for execution from source location <source_location> | none |
| resiliency_drill_validate-for-execution | Preflight resilience drill <drill_name> in service group <service_group> to confirm it is ready to run from source locations <source_locations> | none |
| resiliency_drill_validate-for-execution | Validate resilience drill <drill_name> in service group <service_group> for Reprotect without specifying source locations | none |
| resiliency_drill_validate-for-execution | Preflight resilience drill <drill_name> in service group <service_group> using default validation from source location <source_location> | none |
| resiliency_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 |
| resiliency_drill_resource_add-or-update | Update or exclude the resources of resilience drill <drill_name> in service group <service_group> | none |
| resiliency_drill_start | Start resilience drill <drill_name> in service group <service_group> in Failover mode | none |
@@ -1208,11 +1214,15 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat
| resiliency_drill_run_get | Get drill run <drill_run_name> for drill <drill_name> in service group <service_group> | none |
| resiliency_drill_run_add-notes | Add the note \<notes> to drill run <drill_run_name> for drill <drill_name> in service group <service_group> | none |
| resiliency_drill_run_failover | Start failover for drill run <drill_run_name> of drill <drill_name> in service group <service_group>, using source location <source_location> | none |
| resiliency_drill_run_failover | Fail over selected resources <resource_ids> in drill run <drill_run_name> from physical zones <source_locations>, and automatically continue after fault injection | none |
| resiliency_drill_run_failover | Start failover for drill run <drill_run_name> of resilience drill <drill_name> in service group <service_group> from physical zone <source_location> and continue automatically to failover after fault injection | none |
| resiliency_drill_run_failover | Fail over drill run <drill_run_name> from physical zone <source_location> and pause for manual input instead of automatic failover | none |
| resiliency_drill_run_failover | Fail over selected drill-resource ARM IDs <drill_resource_ids> in drill run <drill_run_name> of drill <drill_name> in service group <service_group> from physical zones <source_locations>, and automatically continue after fault injection | none |
| resiliency_drill_run_resume | Resume paused drill run <drill_run_name> for drill <drill_name> in service group <service_group> and proceed from fault injection to failover | none |
| resiliency_drill_run_mark-complete | Mark the FaultInjection stage of drill run <drill_run_name> for drill <drill_name> in service group <service_group> as complete | none |
| resiliency_drill_run_mark-complete | Complete the fault injection stage of drill run <drill_run_name> for drill <drill_name> in service group <service_group> so the drill run can proceed | none |
| resiliency_drill_run_reprotect | Reprotect failed-over resources in drill run <drill_run_name> for drill <drill_name> in service group <service_group> | none |
| resiliency_drill_run_reprotect | Reprotect only recovery resources <resource_ids> in drill run <drill_run_name> of resilience drill <drill_name> in service group <service_group> | none |
| resiliency_drill_run_reprotect | Start reprotection for all qualified resources in drill run <drill_run_name> without specifying a resource selection | none |
| resiliency_drill_run_resource_get | List all resources of drill run <drill_run_name> for drill <drill_name> in service group <service_group> | none |
| resiliency_drill_run_resource_get | Get resource <resource_name> from drill run <drill_run_name> for drill <drill_name> in service group <service_group> | none |
| resiliency_goal_assignment_get | List all resilience goal assignments in service group <service_group> | none |
@@ -1268,9 +1278,9 @@ The `Interaction` column describes whether a prompt can invoke its tool immediat
| resiliency_recoveryplan_resource_update | Update recoveryplan <recoveryplan_name> in service group <service_group> by removing recovery resource <recovery_resource_id> from its resource membership while retaining the recoveryplan and its other recovery resources | none |
| resiliency_recoveryplan_resource_get | List all resources (members) of recoveryplan <recoveryplan_name> in service group <service_group> | none |
| resiliency_recoveryplan_resource_get | Get the recovery resource <resource_name> for recoveryplan <recoveryplan_name> in service group <service_group> | none |
| resiliency_usageplan_create | Create a resilience usage plan <usage_plan_name> with plan type Basic in resource group <resource_group_name> | none |
| resiliency_usageplan_create | Set up a Basic resilience usage plan named <usage_plan_name> in resource group <resource_group_name> | none |
| resiliency_usageplan_create | Update resilience usage plan <usage_plan_name> in resource group <resource_group_name> to use the Basic plan type | none |
| resiliency_usageplan_create | Create a resilience usage plan <usage_plan_name> with plan type Standard in resource group <resource_group_name> | none |
| resiliency_usageplan_create | Set up a Standard resilience usage plan named <usage_plan_name> in resource group <resource_group_name> | none |
| resiliency_usageplan_create | Update resilience usage plan <usage_plan_name> in resource group <resource_group_name> to use the Standard plan type | none |
| resiliency_usageplan_delete | Delete resilience usage plan <usage_plan_name> from resource group <resource_group_name> | none |
| resiliency_usageplan_delete | Use Azure Resiliency Management usage-plan delete to permanently remove the entire usage plan <usage_plan_name> from resource group <resource_group_name>, not an enrollment | none |
| resiliency_usageplan_delete | Remove the usage plan of service group <service_group> | clarification-required |
@@ -1,12 +1,51 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Core;
using Azure.ResourceManager.ResilienceManagement;
using Microsoft.Mcp.Core.Commands;
namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
internal static class DrillActionValidation
{
private static readonly ResourceType ServiceGroupResourceType = new("Microsoft.Management/serviceGroups");
public static void ValidateSelectedResourceIds(IReadOnlyList<string>? resourceIds, string serviceGroup, ValidationResult validationResult, string? drill = null)
{
if (resourceIds is not null && (resourceIds.Count == 0 ||
resourceIds.Any(id => string.IsNullOrWhiteSpace(id) || !IsSelectedResourceId(id, serviceGroup, drill))))
{
var expectedResource = drill is null
? "recovery-resource ID under the requested service group"
: "drill-resource ID under the requested service group and drill";
validationResult.Errors.Add($"Selected resource IDs must contain at least one full {expectedResource} with a GUID resource name.");
}
}
public static bool IsDrillResourceId(string resourceId, string serviceGroup, string drill)
=> !string.IsNullOrWhiteSpace(resourceId) && IsSelectedResourceId(resourceId, serviceGroup, drill);
private static bool IsSelectedResourceId(string resourceId, string serviceGroup, string? drill)
{
try
{
var parsed = new ResourceIdentifier(resourceId);
ResourceIdentifier? parentId = parsed.Parent;
ResourceIdentifier? serviceGroupId = parentId?.Parent;
return parsed.ResourceType == (drill is null ? RecoveryMembersResource.ResourceType : DrillTargetResource.ResourceType) &&
parentId?.ResourceType == (drill is null ? RecoveryPlanResource.ResourceType : ResilienceManagementDrillResource.ResourceType) &&
(drill is null || string.Equals(parentId.Name, drill, StringComparison.OrdinalIgnoreCase)) &&
serviceGroupId?.ResourceType == ServiceGroupResourceType &&
string.Equals(serviceGroupId.Name, serviceGroup, StringComparison.OrdinalIgnoreCase) &&
Guid.TryParse(parsed.Name, out _);
}
catch (Exception ex) when (ex is ArgumentException or FormatException)
{
return false;
}
}
private static readonly HashSet<string> s_modes = new(StringComparer.OrdinalIgnoreCase)
{
"Failover",
@@ -0,0 +1,214 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// cspell:words slis
using System.ClientModel.Primitives;
using System.Text;
using System.Text.Json;
using Azure.Core;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Azure.ResourceManager.ResilienceManagement;
using Azure.ResourceManager.ResilienceManagement.Models;
namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
internal static class DrillConfiguration
{
public static DrillProperties Create(DrillKind kind, string? goal, string? healthModel, string? sli)
{
DrillProperties properties = kind switch
{
DrillKind.Zonal => new ZonalDrillProperties(),
DrillKind.Regional => new RegionalDrillProperties(),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
if (goal is not null)
{
properties.GoalAssignmentProperties = Read<GoalAssignmentPropertiesOfDrill>(goal, "goal-assignment-properties", null);
}
if (healthModel is not null)
{
properties.HealthModelMonitoringProperties = Read<HealthModelMonitoringProperties>(healthModel, "health-model-monitoring-properties", "healthModelId");
}
if (sli is not null)
{
properties.SliMonitoringProperties = Read<SliMonitoringProperties>(sli, "sli-monitoring-properties", "slis");
}
return properties;
}
public static DrillUpdateProperties Update(string? goal, string? healthModel, string? sli)
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
WriteUpdate(writer, "goalAssignmentProperties", goal, "goal-assignment-properties", null);
WriteUpdate(writer, "healthModelMonitoringProperties", healthModel, "health-model-monitoring-properties", "healthModelId");
WriteUpdate(writer, "sliMonitoringProperties", sli, "sli-monitoring-properties", "slis");
writer.WriteEndObject();
}
using JsonDocument document = JsonDocument.Parse(stream.ToArray());
return new DrillConfigurationPatch(document.RootElement);
}
private static void WriteUpdate(Utf8JsonWriter writer, string property, string? json, string option, string? selection)
{
if (json is null)
{
return;
}
JsonElement value = Parse(json, option, selection, true);
writer.WritePropertyName(property);
value.WriteTo(writer);
}
private static T Read<T>(string json, string option, string? selection) where T : class
{
JsonElement value = Parse(json, option, selection, false);
return ModelReaderWriter.Read<T>(BinaryData.FromString(value.GetRawText()), ModelReaderWriterOptions.Json,
AzureResourceManagerResilienceManagementContext.Default)
?? throw new ArgumentException($"--{option} could not be parsed.");
}
private static JsonElement Parse(string json, string option, string? selection, bool update)
{
if (Encoding.UTF8.GetByteCount(json) > 1024 * 1024)
{
throw new ArgumentException($"--{option} must not exceed 1 MB.");
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
JsonElement root = document.RootElement;
if (update && selection is not null && root.ValueKind == JsonValueKind.Null)
{
return root.Clone();
}
ValidateObject(root, option, selection is null ? ["identity"] : ["identity", selection]);
if (root.TryGetProperty("identity", out JsonElement identity))
{
ValidateIdentity(identity, option, update);
}
else if (!update)
{
throw new ArgumentException($"--{option} requires identity.");
}
if (selection is not null)
{
if (root.TryGetProperty(selection, out JsonElement value))
{
if (selection == "healthModelId")
{
ValidateResourceId(value, "Microsoft.CloudHealth/healthmodels", option);
}
else
{
ValidateSlis(value, option);
}
}
else if (!update)
{
throw new ArgumentException($"--{option} requires {selection}.");
}
}
return root.Clone();
}
catch (JsonException)
{
throw new ArgumentException($"--{option} must be valid JSON.");
}
}
private static void ValidateObject(JsonElement value, string option, string[] allowed)
{
if (value.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException($"--{option} requires a JSON object.");
}
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (JsonProperty property in value.EnumerateObject())
{
if (!allowed.Contains(property.Name, StringComparer.Ordinal) || !names.Add(property.Name))
{
throw new ArgumentException($"--{option} contains unsupported, read-only, or duplicate properties.");
}
}
}
private static void ValidateIdentity(JsonElement identity, string option, bool update)
{
ValidateObject(identity, option, ["type", "userAssignedIdentity"]);
string? type = null;
if (identity.TryGetProperty("type", out JsonElement typeElement))
{
type = RequiredString(typeElement, option);
if (type is not ("None" or "SystemAssigned" or "UserAssigned" or "SystemAssigned, UserAssigned"))
{
throw new ArgumentException($"--{option} identity type must be None, SystemAssigned, UserAssigned, or SystemAssigned, UserAssigned.");
}
}
else if (!update)
{
throw new ArgumentException($"--{option} identity requires type.");
}
if (identity.TryGetProperty("userAssignedIdentity", out JsonElement id))
{
ValidateResourceId(id, "Microsoft.ManagedIdentity/userAssignedIdentities", option);
}
else if (!update && type?.Contains("UserAssigned", StringComparison.Ordinal) == true)
{
throw new ArgumentException($"--{option} requires userAssignedIdentity for a user-assigned identity.");
}
}
private static void ValidateSlis(JsonElement slis, string option)
{
if (slis.ValueKind != JsonValueKind.Array || slis.GetArrayLength() is < 1 or > 2)
{
throw new ArgumentException($"--{option} requires one or two SLIs; use JSON null on update to clear the selection.");
}
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var types = new HashSet<string>(StringComparer.Ordinal);
foreach (JsonElement sli in slis.EnumerateArray())
{
ValidateObject(sli, option, ["sliId", "type"]);
if (!sli.TryGetProperty("sliId", out JsonElement id) || !sli.TryGetProperty("type", out JsonElement typeElement))
{
throw new ArgumentException($"--{option} requires sliId and type for each SLI.");
}
ValidateResourceId(id, "Microsoft.Monitor/slis", option);
string type = RequiredString(typeElement, option);
if (type is not ("Availability" or "Latency") || !ids.Add(id.GetString()!) || !types.Add(type))
{
throw new ArgumentException($"--{option} allows at most one Availability and one Latency SLI, with no duplicate IDs.");
}
}
}
private static string RequiredString(JsonElement value, string option)
{
if (value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(value.GetString()))
{
throw new ArgumentException($"--{option} requires non-empty string values.");
}
return value.GetString()!;
}
private static void ValidateResourceId(JsonElement value, string resourceType, string option)
{
string id = RequiredString(value, option);
try
{
if (!id.StartsWith('/') || new ResourceIdentifier(id).ResourceType != new ResourceType(resourceType))
{
throw new ArgumentException();
}
}
catch (Exception ex) when (ex is ArgumentException or FormatException)
{
throw new ArgumentException($"--{option} requires a full {resourceType} ARM resource ID.");
}
}
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.ClientModel.Primitives;
using System.Text.Json;
using Azure.ResourceManager.ResilienceManagement.Models;
namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
// SDK 1.0.0 reuses create models inside PATCH and writes omitted required-on-create fields as null/empty.
// Its serialization extension point preserves the validated sparse GA configuration without changing transport or LRO handling.
internal sealed class DrillConfigurationPatch(JsonElement configuration) : DrillUpdateProperties
{
private readonly JsonElement _configuration = configuration.Clone();
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
{
base.JsonModelWriteCore(writer, options);
foreach (JsonProperty property in _configuration.EnumerateObject())
{
property.WriteTo(writer);
}
}
}
@@ -15,12 +15,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
Id = "2458b55c-e911-4d4e-91bc-9003a7cbefb0",
Name = "create",
Title = "Create or Update Resilience Drill",
Description = """
Creates, configures, or updates a resilience drill in an Azure service group. Use this tool when asked
to create or provision a resilience drill, including when required drill details must first be clarified.
Supports zonal and regional drills, system-assigned managed identity, RBAC setup, and an optional recovery
plan in the same service group. Returns the created drill definition and provisioning state.
""",
Description = "Creates a new resilience drill for a service group. Use this tool to provision a zonal or regional drill with a managed identity and RBAC setup; the service links the service group's recovery plan automatically. Also configures goal-assignment identity, Health Model monitoring, and Availability/Latency SLI monitoring. Clarify missing creation details first. Returns the drill definition and provisioning state.",
OperationPlane = ToolOperationPlane.Control,
Destructive = true,
Idempotent = true,
@@ -54,9 +49,14 @@ public sealed class DrillCreateCommand(ILogger<DrillCreateCommand> logger, IResi
validationResult.Errors.Add("The resource group name must be a single path segment.");
}
if (options.RecoveryPlan is { } recoveryPlan && recoveryPlan.Contains('/'))
try
{
validationResult.Errors.Add("The recoveryplan name must be a single path segment.");
_ = DrillConfiguration.Create(options.DrillType, options.GoalAssignmentProperties,
options.HealthModelMonitoringProperties, options.SliMonitoringProperties);
}
catch (ArgumentException ex)
{
validationResult.Errors.Add(ex.Message);
}
}
@@ -72,7 +72,8 @@ public sealed class DrillCreateCommand(ILogger<DrillCreateCommand> logger, IResi
options.ResourceGroup,
options.DrillType,
options.RbacSetupMode,
options.RecoveryPlan,
DrillConfiguration.Create(options.DrillType, options.GoalAssignmentProperties,
options.HealthModelMonitoringProperties, options.SliMonitoringProperties),
options.Tenant,
cancellationToken);
@@ -93,12 +94,13 @@ public sealed class DrillCreateCommand(ILogger<DrillCreateCommand> logger, IResi
protected override string GetErrorMessage(Exception ex) => ex switch
{
ArgumentException => ex.Message,
RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Conflict =>
"The drill 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 drill. Verify you have the required permissions.",
RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound =>
"The service group, subscription, resource group, or recoveryplan was not found.",
"The service group, subscription, or resource group was not found.",
RequestFailedException =>
"The drill request failed. Verify the request parameters and try again.",
_ => base.GetErrorMessage(ex)
@@ -15,13 +15,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
Id = "9c4ad84e-7056-46ad-a857-8a81dce0c676",
Name = "update",
Title = "Update Resilience Drill",
Description = """
Updates an existing Azure Resilience Management drill in a service group. Use this command to move the
drill's supporting resources by changing their subscription and region together, change its RBAC setup
mode, or associate a recoveryplan. Supporting-resource relocation is a drill configuration update; do
not use drill resource add-or-update, Azure Resource Mover, or generic resource commands for it. This
tool changes only the supplied drill properties.
""",
Description = "Updates an existing resilience drill in a service group: change RBAC setup mode, move supporting resources to a new subscription and region, or change Health Model, SLI monitoring, and goal-assignment identity. Supports clearing monitoring while preserving all other configuration; omitted properties stay unchanged.",
OperationPlane = ToolOperationPlane.Control,
Destructive = true,
Idempotent = true,
@@ -49,11 +43,6 @@ public sealed class DrillUpdateCommand(ILogger<DrillUpdateCommand> logger, IResi
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 recoveryplan 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.");
@@ -69,9 +58,18 @@ public sealed class DrillUpdateCommand(ILogger<DrillUpdateCommand> logger, IResi
validationResult.Errors.Add("Subscription and region must be specified together.");
}
if (string.IsNullOrWhiteSpace(options.Subscription) && options.RbacSetupMode is null && string.IsNullOrWhiteSpace(options.RecoveryPlan))
if (string.IsNullOrWhiteSpace(options.Subscription) && options.RbacSetupMode is null &&
options.GoalAssignmentProperties is null && options.HealthModelMonitoringProperties is null && options.SliMonitoringProperties is null)
{
validationResult.Errors.Add("Specify at least one property to update: subscription and region, RBAC setup mode, or recoveryplan.");
validationResult.Errors.Add("Specify at least one property to update: subscription and region, RBAC setup mode, goal-assignment properties, Health Model monitoring, or SLI monitoring.");
}
try
{
_ = DrillConfiguration.Update(options.GoalAssignmentProperties, options.HealthModelMonitoringProperties, options.SliMonitoringProperties);
}
catch (ArgumentException ex)
{
validationResult.Errors.Add(ex.Message);
}
}
@@ -85,7 +83,7 @@ public sealed class DrillUpdateCommand(ILogger<DrillUpdateCommand> logger, IResi
options.Subscription,
options.Region,
options.RbacSetupMode,
options.RecoveryPlan,
DrillConfiguration.Update(options.GoalAssignmentProperties, options.HealthModelMonitoringProperties, options.SliMonitoringProperties),
options.Tenant,
cancellationToken);
@@ -106,12 +104,13 @@ public sealed class DrillUpdateCommand(ILogger<DrillUpdateCommand> logger, IResi
protected override string GetErrorMessage(Exception ex) => ex switch
{
ArgumentException => ex.Message,
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 recoveryplan was not found.",
"The drill, service group, or subscription was not found.",
RequestFailedException =>
"The drill update request failed. Verify the request parameters and try again.",
_ => base.GetErrorMessage(ex)
@@ -5,6 +5,7 @@ using System.Net;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Azure.Mcp.Tools.ResilienceManagement.Options.Drills;
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;
@@ -15,7 +16,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
Id = "90da933a-bbf3-48ec-a03b-5db38e0c0a3b",
Name = "validate-for-execution",
Title = "Validate Resilience Drill For Execution",
Description = "Validates a named resilience drill for execution from specified physical source locations. Use this command to validate, preflight, or check drill execution readiness before running the drill. It starts validation and returns the operation ID; it does not get or list drill definitions or resources.",
Description = "Validates a resilience drill for execution from source locations in its service group. Preflight drill readiness without starting fault injection, or validate Reprotect without sources. Otherwise source locations are required. Optionally select Failover, Reprotect, FailoverReverse, or ReprotectReverse. Returns a validation operation ID.",
OperationPlane = ToolOperationPlane.Control,
Destructive = false,
Idempotent = false,
@@ -38,7 +39,16 @@ public sealed class DrillValidateForExecutionCommand(
ValidatePathSegment(options.ServiceGroup, "--service-group", validationResult);
ValidatePathSegment(options.Drill, "--drill", validationResult);
if (options.SourceLocations.Length == 0 || options.SourceLocations.Any(string.IsNullOrWhiteSpace))
if (options.OperationName is { } operation && !Enum.IsDefined(operation))
{
validationResult.Errors.Add("--operation-name must be Failover, Reprotect, FailoverReverse, or ReprotectReverse.");
}
if (options.OperationName != DrillExecutionOperation.Reprotect && options.SourceLocations is not { Length: > 0 })
{
validationResult.Errors.Add("--source-locations is required unless --operation-name is Reprotect.");
}
else if (options.SourceLocations is { } locations && (locations.Length == 0 || locations.Any(string.IsNullOrWhiteSpace)))
{
validationResult.Errors.Add("--source-locations must contain at least one non-empty location.");
}
@@ -51,10 +61,16 @@ public sealed class DrillValidateForExecutionCommand(
{
try
{
DrillRunTask? operationName = null;
if (options.OperationName is { } operation)
{
operationName = new DrillRunTask(operation.ToString());
}
DrillValidateForExecutionResult validation = await _resilienceManagementService.ValidateDrillForExecutionAsync(
options.ServiceGroup,
options.Drill,
options.SourceLocations,
operationName,
options.Tenant,
cancellationToken);
@@ -5,6 +5,7 @@ using System.ClientModel.Primitives;
using System.Net;
using System.Text;
using System.Text.Json;
using Azure.Core;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Azure.Mcp.Tools.ResilienceManagement.Options.Drills.Resources;
using Azure.Mcp.Tools.ResilienceManagement.Services;
@@ -20,12 +21,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills.Resources;
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.
""",
Description = "Updates or excludes the resources of a resilience drill in a service group, or adds new drill resources. Configure fault duration and target inclusions/exclusions using drill-resource ARM IDs, not VM IDs. Starts the resource update and returns its operation ID.",
OperationPlane = ToolOperationPlane.Control,
Destructive = true,
Idempotent = true,
@@ -124,9 +120,9 @@ public sealed class DrillAddOrUpdateResourcesCommand(ILogger<DrillAddOrUpdateRes
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);
ValidateIncludeOrUpdate(include.RootElement, "--include-resources", options);
ValidateIncludeOrUpdate(update.RootElement, "--update-resources", options);
ValidateExclude(exclude.RootElement, options);
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
@@ -162,7 +158,7 @@ public sealed class DrillAddOrUpdateResourcesCommand(ILogger<DrillAddOrUpdateRes
}
}
private static void ValidateIncludeOrUpdate(JsonElement resources, string optionName)
private static void ValidateIncludeOrUpdate(JsonElement resources, string optionName, DrillAddOrUpdateResourcesOptions options)
{
if (resources.ValueKind != JsonValueKind.Array)
{
@@ -178,10 +174,15 @@ public sealed class DrillAddOrUpdateResourcesCommand(ILogger<DrillAddOrUpdateRes
{
throw new ArgumentException($"Each resource in {optionName} must be an object with a non-empty \"id\" string.");
}
if (!DrillActionValidation.IsDrillResourceId(idElement.GetString()!, options.ServiceGroup, options.Drill))
{
throw new ArgumentException($"{optionName} requires full drill-resource IDs under the requested service group and drill, not underlying Azure resource IDs.");
}
}
}
private static void ValidateExclude(JsonElement resources)
private static void ValidateExclude(JsonElement resources, DrillAddOrUpdateResourcesOptions options)
{
if (resources.ValueKind != JsonValueKind.Array)
{
@@ -190,9 +191,10 @@ public sealed class DrillAddOrUpdateResourcesCommand(ILogger<DrillAddOrUpdateRes
foreach (JsonElement resource in resources.EnumerateArray())
{
if (resource.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(resource.GetString()))
if (resource.ValueKind != JsonValueKind.String ||
!DrillActionValidation.IsDrillResourceId(resource.GetString()!, options.ServiceGroup, options.Drill))
{
throw new ArgumentException("Each value in --exclude-resources must be a non-empty Azure resource ID string.");
throw new ArgumentException("--exclude-resources requires full drill-resource ID strings under the requested service group and drill, not underlying Azure resource IDs.");
}
}
}
@@ -2,10 +2,8 @@
// Licensed under the MIT License.
using System.Net;
using Azure.Core;
using Azure.Mcp.Tools.ResilienceManagement.Options.Drills.Runs;
using Azure.Mcp.Tools.ResilienceManagement.Services;
using Azure.ResourceManager.ResilienceManagement;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Models.Command;
@@ -16,7 +14,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills.Runs;
Id = "8fe94d6f-feb1-468c-bdbd-c93384e4639a",
Name = "failover",
Title = "Fail Over a Resilience Drill Run",
Description = "Initiates failover for a run of a resilience drill from specified physical Azure zones in an Azure service group.",
Description = "Starts failover for a resilience drill run in a service group from physical source zones. Optional selected drill resources limit fault injection and failover; auto-failover continues automatically to failover instead of pausing for manual input after fault injection.",
OperationPlane = ToolOperationPlane.Control,
Destructive = true,
Idempotent = false,
@@ -27,7 +25,6 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills.Runs;
public sealed class DrillRunFailoverCommand(ILogger<DrillRunFailoverCommand> logger, IResilienceManagementService resilienceManagementService)
: AuthenticatedCommand<DrillRunFailoverOptions, DrillRunFailoverCommand.DrillRunFailoverCommandResult>
{
private static readonly ResourceType ServiceGroupResourceType = new("Microsoft.Management/serviceGroups");
private readonly ILogger<DrillRunFailoverCommand> _logger = logger;
private readonly IResilienceManagementService _resilienceManagementService = resilienceManagementService;
@@ -38,19 +35,16 @@ public sealed class DrillRunFailoverCommand(ILogger<DrillRunFailoverCommand> log
ValidatePathSegment(options.Drill, "drill", validationResult);
ValidatePathSegment(options.DrillRun, "drill run", validationResult);
if (options.SourceLocations.Length == 0)
if (options.SourceLocations is null or { Length: 0 })
{
validationResult.Errors.Add("At least one source location is required.");
}
else if (options.SourceLocations.Any(location => !IsValidPhysicalZone(location)))
else if (options.SourceLocations.Any(location => string.IsNullOrWhiteSpace(location) || !IsValidPhysicalZone(location)))
{
validationResult.Errors.Add("Each source location must use the physical Azure zone format '<region>-az<zone-number>', such as 'eastus-az1'.");
}
if (options.SelectedResourceIds?.Any(resourceId => !IsRecoveryResourceIdForServiceGroup(resourceId, options.ServiceGroup)) == true)
{
validationResult.Errors.Add("Each selected resource ID must be a full recovery-resource ID under the requested service group with a GUID resource name.");
}
DrillActionValidation.ValidateSelectedResourceIds(options.SelectedResourceIds, options.ServiceGroup, validationResult, drill: options.Drill);
}
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, DrillRunFailoverOptions options, CancellationToken cancellationToken)
@@ -133,24 +127,5 @@ public sealed class DrillRunFailoverCommand(ILogger<DrillRunFailoverCommand> log
return true;
}
private static bool IsRecoveryResourceIdForServiceGroup(string resourceId, string serviceGroup)
{
try
{
var parsed = new ResourceIdentifier(resourceId);
ResourceIdentifier? recoveryPlanId = parsed.Parent;
ResourceIdentifier? serviceGroupId = recoveryPlanId?.Parent;
return parsed.ResourceType == RecoveryMembersResource.ResourceType &&
recoveryPlanId?.ResourceType == RecoveryPlanResource.ResourceType &&
serviceGroupId?.ResourceType == ServiceGroupResourceType &&
string.Equals(serviceGroupId.Name, serviceGroup, StringComparison.OrdinalIgnoreCase) &&
Guid.TryParse(parsed.Name, out _);
}
catch (Exception ex) when (ex is ArgumentException or FormatException)
{
return false;
}
}
public sealed record DrillRunFailoverCommandResult(string OperationId, string DrillRun, bool Accepted);
}
@@ -14,7 +14,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands.Drills.Runs;
Id = "a331e69f-4669-40de-97ad-10a1cebe4b54",
Name = "reprotect",
Title = "Reprotect a Resilience Drill Run",
Description = "Initiates reprotection for failed-over resources in a resilience drill run.",
Description = "Initiates reprotection for failed-over resources in a resilience drill run. Optionally select recovery-resource ARM IDs in the same service group; omit the selection to reprotect all qualified resources.",
OperationPlane = ToolOperationPlane.Control,
Destructive = true,
Idempotent = false,
@@ -34,6 +34,7 @@ public sealed class DrillRunReprotectCommand(ILogger<DrillRunReprotectCommand> l
ValidatePathSegment(options.ServiceGroup, "service group", validationResult);
ValidatePathSegment(options.Drill, "drill", validationResult);
ValidatePathSegment(options.DrillRun, "drill run", validationResult);
DrillActionValidation.ValidateSelectedResourceIds(options.SelectedResourceIds, options.ServiceGroup, validationResult);
}
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, DrillRunReprotectOptions options, CancellationToken cancellationToken)
@@ -44,6 +45,7 @@ public sealed class DrillRunReprotectCommand(ILogger<DrillRunReprotectCommand> l
options.ServiceGroup,
options.Drill,
options.DrillRun,
options.SelectedResourceIds,
options.Tenant,
cancellationToken);
@@ -55,7 +55,7 @@ public sealed class RecoveryPlanValidateForOperationCommand(
{
try
{
_ = TryGetOperationName(options.OperationName, out RecoveryOperationNames operationName);
_ = TryGetOperationName(options.OperationName, out RecoveryOperationName operationName);
RecoveryPlanValidateForOperationResult result = await _resilienceManagementService.ValidateRecoveryPlanForOperationAsync(
options.ServiceGroup,
options.RecoveryPlan,
@@ -78,35 +78,35 @@ public sealed class RecoveryPlanValidateForOperationCommand(
return context.Response;
}
private static bool TryGetOperationName(string operationName, out RecoveryOperationNames value)
private static bool TryGetOperationName(string operationName, out RecoveryOperationName value)
{
if (string.Equals(operationName, "Failover", StringComparison.OrdinalIgnoreCase))
{
value = RecoveryOperationNames.Failover;
value = RecoveryOperationName.Failover;
return true;
}
if (string.Equals(operationName, "FailoverCommit", StringComparison.OrdinalIgnoreCase))
{
value = RecoveryOperationNames.FailoverCommit;
value = RecoveryOperationName.FailoverCommit;
return true;
}
if (string.Equals(operationName, "Reprotect", StringComparison.OrdinalIgnoreCase))
{
value = RecoveryOperationNames.Reprotect;
value = RecoveryOperationName.Reprotect;
return true;
}
if (string.Equals(operationName, "TestFailover", StringComparison.OrdinalIgnoreCase))
{
value = RecoveryOperationNames.TestFailover;
value = RecoveryOperationName.TestFailover;
return true;
}
if (string.Equals(operationName, "TestFailoverCleanup", StringComparison.OrdinalIgnoreCase))
{
value = RecoveryOperationNames.TestFailoverCleanup;
value = RecoveryOperationName.TestFailoverCleanup;
return true;
}
@@ -26,7 +26,6 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Commands;
[JsonSerializable(typeof(GoalResourceGetCommand.GoalResourceGetCommandResult))]
[JsonSerializable(typeof(GoalResourceInfo))]
[JsonSerializable(typeof(GoalResourceInfoProperties))]
[JsonSerializable(typeof(GoalResourceServiceGroupMembership))]
[JsonSerializable(typeof(GoalResourceInfoSystemData))]
[JsonSerializable(typeof(UsagePlanGetCommand.UsagePlanGetCommandResult))]
[JsonSerializable(typeof(UsagePlanCreateCommand.UsagePlanCreateCommandResult))]
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace Azure.Mcp.Tools.ResilienceManagement.Models;
public sealed record DrillAddOrUpdateResourcesResult(string OperationId, bool HasCompleted);
public sealed record DrillAddOrUpdateResourcesResult(string OperationId, [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] bool HasCompleted);
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
namespace Azure.Mcp.Tools.ResilienceManagement.Models;
public enum DrillExecutionOperation
{
Failover,
Reprotect,
FailoverReverse,
ReprotectReverse
}
@@ -13,4 +13,5 @@ public sealed record DrillInfo(
[property: JsonPropertyName("location")] string? Location = null,
[property: JsonPropertyName("tags")] IReadOnlyDictionary<string, string>? Tags = null,
[property: JsonPropertyName("properties")] JsonElement Properties = default,
[property: JsonPropertyName("systemData")] JsonElement SystemData = default);
[property: JsonPropertyName("systemData")] JsonElement SystemData = default,
[property: JsonPropertyName("identity")] JsonElement Identity = default);
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace Azure.Mcp.Tools.ResilienceManagement.Models;
public sealed record DrillResyncReadinessResult(string OperationId, bool HasCompleted);
public sealed record DrillResyncReadinessResult(string OperationId, [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] bool HasCompleted);
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace Azure.Mcp.Tools.ResilienceManagement.Models;
public sealed record DrillRunMarkCompleteResult(string OperationId, bool HasCompleted);
public sealed record DrillRunMarkCompleteResult(string OperationId, [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] bool HasCompleted);
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace Azure.Mcp.Tools.ResilienceManagement.Models;
public sealed record DrillValidateForExecutionResult(string OperationId, bool HasCompleted);
public sealed record DrillValidateForExecutionResult(string OperationId, [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] bool HasCompleted);
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Azure.Mcp.Tools.ResilienceManagement.Models;
@@ -12,9 +13,10 @@ public sealed record GoalAssignmentInfo(
[property: JsonPropertyName("systemData")] GoalAssignmentInfoSystemData? SystemData = null);
public sealed record GoalAssignmentInfoProperties(
[property: JsonPropertyName("goalAssignmentType")] string GoalAssignmentType,
[property: JsonPropertyName("goalTemplateId")] string GoalTemplateId,
[property: JsonPropertyName("provisioningState")] string ProvisioningState);
[property: JsonPropertyName("provisioningState")] string ProvisioningState,
[property: JsonPropertyName("requireZonalResiliency")] bool? RequireZonalResiliency = null,
[property: JsonPropertyName("serviceLevelResources")] JsonElement? ServiceLevelResources = null,
[property: JsonPropertyName("errorDetails")] JsonElement? ErrorDetails = null);
public sealed record GoalAssignmentInfoSystemData(
[property: JsonPropertyName("createdAt")] string CreatedAt,
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Azure.Mcp.Tools.ResilienceManagement.Models;
@@ -12,19 +13,9 @@ public sealed record GoalResourceInfo(
[property: JsonPropertyName("systemData")] GoalResourceInfoSystemData? SystemData = null);
public sealed record GoalResourceInfoProperties(
[property: JsonPropertyName("disasterRecoveryAttestationStatus")] string DisasterRecoveryAttestationStatus,
[property: JsonPropertyName("disasterRecoveryGoalParticipation")] string DisasterRecoveryGoalParticipation,
[property: JsonPropertyName("exclusionReasonForDisasterRecoveryGoals")] string ExclusionReasonForDisasterRecoveryGoals,
[property: JsonPropertyName("exclusionReasonForHighAvailabilityGoals")] string ExclusionReasonForHighAvailabilityGoals,
[property: JsonPropertyName("highAvailabilityAttestationStatus")] string HighAvailabilityAttestationStatus,
[property: JsonPropertyName("highAvailabilityGoalParticipation")] string HighAvailabilityGoalParticipation,
[property: JsonPropertyName("provisioningState")] string ProvisioningState,
[property: JsonPropertyName("resourceArmId")] string ResourceArmId,
[property: JsonPropertyName("serviceGroupMemberships")] IReadOnlyList<GoalResourceServiceGroupMembership>? ServiceGroupMemberships = null);
public sealed record GoalResourceServiceGroupMembership(
[property: JsonPropertyName("membershipType")] string MembershipType,
[property: JsonPropertyName("serviceGroupId")] string ServiceGroupId);
[property: JsonPropertyName("zonalResiliency")] JsonElement? ZonalResiliency = null);
public sealed record GoalResourceInfoSystemData(
[property: JsonPropertyName("createdAt")] string CreatedAt,
@@ -5,6 +5,5 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Models;
public enum UsagePlanKind
{
Basic,
Standard
}
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// cspell:words slis
using Azure.Mcp.Core.Options;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Microsoft.Mcp.Core.Options;
@@ -30,8 +32,14 @@ public sealed class DrillCreateOptions
[Option(Description = "The RBAC setup mode. Supported values: AutomatedCustomRole, AutomatedBuiltinRoles, Manual.")]
public required DrillRbacSetupMode RbacSetupMode { get; set; }
[Option(Name = "recoveryplan", Description = "The optional recoveryplan name in the same service group to associate with the drill.")]
public string? RecoveryPlan { get; set; }
[Option(Description = "JSON goal-assignment properties containing identity (type and optional userAssignedIdentity). goalAssignmentId is read-only and must not be supplied.")]
public string? GoalAssignmentProperties { get; set; }
[Option(Description = "JSON Health Model monitoring properties containing identity and the full Microsoft.CloudHealth/healthmodels ARM ID as healthModelId.")]
public string? HealthModelMonitoringProperties { get; set; }
[Option(Description = "JSON SLI monitoring properties containing identity and slis: one or two objects with sliId (Microsoft.Monitor/slis ARM ID) and type (Availability or Latency). IDs and types must be unique.")]
public string? SliMonitoringProperties { get; set; }
[Option(Description = OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// cspell:words slis
using Azure.Mcp.Core.Options;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Microsoft.Mcp.Core.Options;
@@ -24,8 +26,14 @@ public sealed class DrillUpdateOptions
[Option(Description = "The RBAC setup mode. Supported values: AutomatedCustomRole, AutomatedBuiltinRoles, Manual.")]
public DrillRbacSetupMode? RbacSetupMode { get; set; }
[Option(Name = "recoveryplan", Description = "The recoveryplan name in the same service group to associate with the drill.")]
public string? RecoveryPlan { get; set; }
[Option(Description = "JSON goal-assignment properties to update, containing identity (type and optional userAssignedIdentity). goalAssignmentId is read-only. Omit to preserve the current configuration.")]
public string? GoalAssignmentProperties { get; set; }
[Option(Description = "JSON Health Model monitoring properties to update (identity and/or healthModelId). Pass the string 'null' as this JSON payload to clear monitoring; omit to preserve it.")]
public string? HealthModelMonitoringProperties { get; set; }
[Option(Description = "JSON SLI monitoring properties to update (identity and/or slis). A supplied slis array replaces the selection and must contain one or two unique sliId/type entries (Availability or Latency). Pass the string 'null' as this JSON payload to clear; omit to preserve.")]
public string? SliMonitoringProperties { get; set; }
[Option(Description = OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
@@ -2,6 +2,7 @@
// 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;
@@ -14,8 +15,11 @@ public sealed class DrillValidateForExecutionOptions
[Option(Description = ResilienceManagementOptionDescriptions.Drill)]
public required string Drill { get; set; }
[Option(Description = "The physical source locations from which resources will be failed over or faulted.")]
public required string[] SourceLocations { get; set; }
[Option(Description = "Physical source locations from which resources will be failed over or faulted, such as 'westus2-az1'. Required unless operation-name is Reprotect, including when operation-name is omitted.")]
public string[]? SourceLocations { get; set; }
[Option(Description = "Optional operation to validate: Failover, Reprotect, FailoverReverse, or ReprotectReverse. Omit to use the service's default validation.")]
public DrillExecutionOperation? OperationName { get; set; }
[Option(Description = OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
@@ -19,16 +19,16 @@ public sealed class DrillAddOrUpdateResourcesOptions
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\"}].")]
"A JSON array of resources to include in the drill. Each item has an \"id\" identifying a drill resource, NOT the underlying VM, " +
"and optional \"faultProperties\". Example: [{\"id\":\"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/00000000-0000-0000-0000-000000000001\"}].")]
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) " +
"A JSON array of already-included drill resources to update. Each item has an \"id\" identifying a drill resource, NOT the underlying VM, " +
"and optional \"faultProperties\".")]
public string? UpdateResources { get; set; }
[Option(Description = "A JSON array of ARM resource ID strings to exclude from the drill.")]
[Option(Description = "A JSON array of drill-resource ARM ID strings under the requested service group and drill to exclude from the drill, NOT underlying VM IDs.")]
public string? ExcludeResources { get; set; }
[Option(Description = "Whether to force inclusion and update of the resources. Allowed values: Enable, Disable.")]
@@ -17,13 +17,13 @@ public sealed class DrillRunFailoverOptions
[Option(Description = "The name of the drill run on which to initiate failover.")]
public required string DrillRun { get; set; }
[Option(Description = "The physical Azure zones from which resources will be failed over, using values such as 'eastus-az1'.")]
[Option(Description = "The physical Azure zones from which resources will be failed over, such as 'eastus-az1'.")]
public required string[] SourceLocations { get; set; }
[Option(Description = "The ARM IDs of recovery resources to fail over. Omit this option to process all qualified resources in the source locations.")]
[Option(Description = "The full drill-resource ARM IDs under the requested service group and drill to fail over. Omit this option to process all qualified resources in the source locations.")]
public string[]? SelectedResourceIds { get; set; }
[Option(Description = "Whether to proceed automatically from fault injection to failover without pausing for manual input.")]
[Option(Description = "Whether to proceed automatically from fault injection to failover without pausing for manual input. Defaults to false.")]
public bool AutoFailover { get; set; }
[Option(Description = OptionDescriptions.Tenant)]
@@ -17,6 +17,9 @@ public sealed class DrillRunReprotectOptions
[Option(Description = "The name of the drill run whose failed-over resources will be reprotected.")]
public required string DrillRun { get; set; }
[Option(Description = "Optional full recovery-resource ARM IDs in this service group to reprotect. Omit to process all qualified resources.")]
public string[]? SelectedResourceIds { get; set; }
[Option(Description = OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
}
@@ -15,7 +15,7 @@ public class UsagePlanCreateOptions : ISubscriptionOption
[Option(Description = "The name of the usage plan.")]
public required string UsagePlan { get; set; }
[Option(Description = "The plan type of the usage plan. Supported values: Basic, Standard.")]
[Option(Description = "The plan type of the usage plan. Supported value: Standard.")]
public required UsagePlanKind PlanType { get; set; }
[Option(Description = OptionDescriptions.Subscription)]
@@ -47,7 +47,7 @@ public interface IResilienceManagementService
Task<RecoveryPlanValidateForReprotectResult> ValidateRecoveryPlanForReprotectAsync(string serviceGroup, string recoveryPlan, IReadOnlyList<string>? selectedResourceIds = null, string? tenant = null, CancellationToken cancellationToken = default);
Task<RecoveryPlanValidateForOperationResult> ValidateRecoveryPlanForOperationAsync(string serviceGroup, string recoveryPlan, RecoveryOperationNames operationName, string? tenant = null, CancellationToken cancellationToken = default);
Task<RecoveryPlanValidateForOperationResult> ValidateRecoveryPlanForOperationAsync(string serviceGroup, string recoveryPlan, RecoveryOperationName operationName, string? tenant = null, CancellationToken cancellationToken = default);
Task<RecoveryPlanReadinessResult> CheckRecoveryPlanReadinessAsync(string serviceGroup, string recoveryPlan, string? tenant = null, CancellationToken cancellationToken = default);
@@ -73,7 +73,7 @@ public interface IResilienceManagementService
Task<DrillResyncReadinessResult> CheckDrillResyncReadinessAsync(string serviceGroup, string drill, string? tenant = null, CancellationToken cancellationToken = default);
Task<DrillValidateForExecutionResult> ValidateDrillForExecutionAsync(string serviceGroup, string drill, IEnumerable<string> sourceLocations, string? tenant = null, CancellationToken cancellationToken = default);
Task<DrillValidateForExecutionResult> ValidateDrillForExecutionAsync(string serviceGroup, string drill, IEnumerable<string>? sourceLocations = null, DrillRunTask? operationName = null, string? tenant = null, CancellationToken cancellationToken = default);
Task<DrillAddOrUpdateResourcesResult> AddOrUpdateDrillResourcesAsync(string serviceGroup, string drill, AddOrUpdateResourcesContent content, string? tenant = null, CancellationToken cancellationToken = default);
@@ -87,13 +87,13 @@ public interface IResilienceManagementService
Task<DrillRunMarkCompleteResult> MarkDrillRunCompleteAsync(string serviceGroup, string drill, string drillRun, string stage, string? tenant = null, CancellationToken cancellationToken = default);
Task<string> ReprotectDrillRunAsync(string serviceGroup, string drill, string drillRun, string? tenant = null, CancellationToken cancellationToken = default);
Task<string> ReprotectDrillRunAsync(string serviceGroup, string drill, string drillRun, IReadOnlyList<string>? selectedResourceIds = null, string? tenant = null, CancellationToken cancellationToken = default);
Task<string> EndDrillAsync(string serviceGroup, string drill, string attestation, string attestationNotes, string? tenant = 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, CancellationToken cancellationToken = default);
Task<DrillInfo> UpdateDrillAsync(string serviceGroup, string drill, string? subscription = null, string? region = null, DrillRbacSetupMode? rbacSetupMode = null, DrillUpdateProperties? properties = null, string? tenant = null, CancellationToken cancellationToken = default);
Task<DrillInfo> CreateDrillAsync(string serviceGroup, string drill, string subscription, string region, string? resourceGroup, DrillKind drillType, DrillRbacSetupMode rbacSetupMode, string? recoveryPlan = null, string? tenant = null, CancellationToken cancellationToken = default);
Task<DrillInfo> CreateDrillAsync(string serviceGroup, string drill, string subscription, string region, string? resourceGroup, DrillKind drillType, DrillRbacSetupMode rbacSetupMode, DrillProperties? properties = null, string? tenant = null, CancellationToken cancellationToken = default);
Task DeleteDrillAsync(string serviceGroup, string drill, string? tenant = null, CancellationToken cancellationToken = default);
@@ -54,22 +54,25 @@ public sealed class ResilienceManagementService(IAzureService azureService)
var serviceGroupId = new ResourceIdentifier($"/providers/Microsoft.Management/serviceGroups/{serviceGroup}");
GoalAssignmentCollection goalAssignments = armClient.GetGoalAssignments(serviceGroupId);
GoalAssignmentResource resource = await goalAssignments.GetAsync(goalAssignment, cancellationToken);
Response<GoalAssignmentResource> response = await goalAssignments.GetAsync(goalAssignment, cancellationToken);
return MapGoalAssignment(resource.Data);
return MapGoalAssignment(response.Value.Data, response.GetRawResponse().Content);
}
private static GoalAssignmentInfo MapGoalAssignment(GoalAssignmentData data)
internal static GoalAssignmentInfo MapGoalAssignment(GoalAssignmentData data, BinaryData responseContent)
{
var props = data.Properties;
var systemData = data.SystemData;
using JsonDocument document = JsonDocument.Parse(responseContent);
JsonElement rawProperties = GetOptionalObject(document.RootElement, "properties");
var mappedProperties = props is null
? null
: new GoalAssignmentInfoProperties(
GoalAssignmentType: props.GoalAssignmentType.ToString(),
GoalTemplateId: props.GoalTemplateId?.ToString() ?? string.Empty,
ProvisioningState: props.ProvisioningState?.ToString() ?? string.Empty);
ProvisioningState: props.ProvisioningState?.ToString() ?? string.Empty,
RequireZonalResiliency: GetOptionalBoolean(rawProperties, "requireZonalResiliency"),
ServiceLevelResources: GetOptionalRawValue(rawProperties, "serviceLevelResources", JsonValueKind.Array),
ErrorDetails: GetOptionalRawValue(rawProperties, "errorDetails", JsonValueKind.Object));
var mappedSystemData = systemData is null
? null
@@ -273,34 +276,24 @@ public sealed class ResilienceManagementService(IAzureService azureService)
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, cancellationToken: cancellationToken);
var goalMemberId = GoalMembersResource.CreateResourceIdentifier(serviceGroup, goalAssignment, goalResource);
GoalMembersResource resource = await armClient.GetGoalMembersResource(goalMemberId).GetAsync(cancellationToken);
Response<GoalMembersResource> response = await armClient.GetGoalMembersResource(goalMemberId).GetAsync(cancellationToken);
return MapGoalResource(resource.Data);
return MapGoalResource(response.Value.Data, response.GetRawResponse().Content);
}
private static GoalResourceInfo MapGoalResource(GoalMembersData data)
internal static GoalResourceInfo MapGoalResource(GoalMembersData data, BinaryData responseContent)
{
var props = data.Properties;
var systemData = data.SystemData;
using JsonDocument document = JsonDocument.Parse(responseContent);
JsonElement rawProperties = GetOptionalObject(document.RootElement, "properties");
var mappedProperties = props is null
? null
: new GoalResourceInfoProperties(
DisasterRecoveryAttestationStatus: props.DisasterRecoveryAttestationStatus.ToString() ?? string.Empty,
DisasterRecoveryGoalParticipation: props.DisasterRecoveryGoalParticipation.ToString() ?? string.Empty,
ExclusionReasonForDisasterRecoveryGoals: props.ExclusionReasonForDisasterRecoveryGoals.ToString() ?? string.Empty,
ExclusionReasonForHighAvailabilityGoals: props.ExclusionReasonForHighAvailabilityGoals.ToString() ?? string.Empty,
HighAvailabilityAttestationStatus: props.HighAvailabilityAttestationStatus.ToString() ?? string.Empty,
HighAvailabilityGoalParticipation: props.HighAvailabilityGoalParticipation.ToString() ?? string.Empty,
ProvisioningState: props.ProvisioningState?.ToString() ?? string.Empty,
ResourceArmId: props.ResourceArmId?.ToString() ?? string.Empty,
ServiceGroupMemberships: props.ServiceGroupMemberships is null || props.ServiceGroupMemberships.Count == 0
? null
: props.ServiceGroupMemberships
.Select(m => new GoalResourceServiceGroupMembership(
MembershipType: m.MembershipType.ToString() ?? string.Empty,
ServiceGroupId: m.ServiceGroupId?.ToString() ?? string.Empty))
.ToList());
ZonalResiliency: GetOptionalRawValue(rawProperties, "zonalResiliency", JsonValueKind.Object));
var mappedSystemData = systemData is null
? null
@@ -606,7 +599,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
IReadOnlyList<string>? selectedResourceIds,
string? userConsent)
{
var properties = new FailoverRequestProperties(sourceLocations);
var properties = new ResilienceManagementFailoverProperties(sourceLocations);
foreach (string selectedResourceId in selectedResourceIds ?? [])
{
properties.SelectedResourceIds.Add(new ResourceIdentifier(selectedResourceId));
@@ -614,10 +607,10 @@ public sealed class ResilienceManagementService(IAzureService azureService)
if (userConsent is not null)
{
properties.ExecutionConfigurationsUserConsent = new UserConsent(userConsent);
properties.ExecutionConfigurationsUserConsent = new RecoveryActionUserConsent(userConsent);
}
return new ResilienceManagementFailoverContent(FailoverDirectionTypes.FromSpecificLocations)
return new ResilienceManagementFailoverContent(ResilienceManagementFailoverDirection.FromSpecificLocations)
{
FailoverRequestProperties = properties
};
@@ -679,6 +672,14 @@ public sealed class ResilienceManagementService(IAzureService azureService)
? property
: default;
// Clones so the value outlives the parsed response document.
private static JsonElement? GetOptionalRawValue(JsonElement element, string propertyName, JsonValueKind kind) =>
element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out JsonElement property) &&
property.ValueKind == kind
? property.Clone()
: null;
private static string? GetOptionalString(JsonElement element, string propertyName) =>
element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(propertyName, out JsonElement property) &&
@@ -712,7 +713,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
{
RecoveryMembersData? resource = qualification?.RecoveryResource;
RecoveryResourceProperties? resourceProperties = resource?.Properties;
OperationQualificationDetails? details = qualification?.OperationQualificationDetails;
RecoveryOperationQualificationDetails? details = qualification?.OperationQualificationDetails;
return new RecoveryPlanFailoverQualification(
resource?.Id?.ToString() ?? string.Empty,
resourceProperties?.RecoveryResourceUniqueId ?? resource?.Name ?? string.Empty,
@@ -776,7 +777,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
return content;
}
public Task<RecoveryPlanValidateForOperationResult> ValidateRecoveryPlanForOperationAsync(string serviceGroup, string recoveryPlan, RecoveryOperationNames operationName, string? tenant = null, CancellationToken cancellationToken = default)
public Task<RecoveryPlanValidateForOperationResult> ValidateRecoveryPlanForOperationAsync(string serviceGroup, string recoveryPlan, RecoveryOperationName operationName, string? tenant = null, CancellationToken cancellationToken = default)
{
return ExecuteWithTimeoutAsync(
token => ValidateRecoveryPlanForOperationCoreAsync(serviceGroup, recoveryPlan, operationName, tenant, token),
@@ -785,7 +786,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
cancellationToken);
}
private async Task<RecoveryPlanValidateForOperationResult> ValidateRecoveryPlanForOperationCoreAsync(string serviceGroup, string recoveryPlan, RecoveryOperationNames operationName, string? tenant, CancellationToken cancellationToken)
private async Task<RecoveryPlanValidateForOperationResult> ValidateRecoveryPlanForOperationCoreAsync(string serviceGroup, string recoveryPlan, RecoveryOperationName operationName, string? tenant, CancellationToken cancellationToken)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, cancellationToken: cancellationToken);
@@ -1138,14 +1139,14 @@ public sealed class ResilienceManagementService(IAzureService azureService)
}
}
internal static RecoveryPlanReadinessError? CreateReadinessError(JobErrorInfo? error)
internal static RecoveryPlanReadinessError? CreateReadinessError(ResilienceManagementJobErrorInfo? error)
{
return error is null
? null
: new RecoveryPlanReadinessError(error.ErrorCode, error.ErrorMessage, error.Recommendations ?? []);
}
private static List<RecoveryPlanReadinessFailedTask> GetFailedTasks(IReadOnlyList<JobTaskDetail>? tasks)
private static List<RecoveryPlanReadinessFailedTask> GetFailedTasks(IReadOnlyList<ResilienceManagementJobTaskDetail>? tasks)
{
var failedTasks = new List<RecoveryPlanReadinessFailedTask>();
if (tasks is not null)
@@ -1156,9 +1157,9 @@ public sealed class ResilienceManagementService(IAzureService azureService)
return failedTasks;
}
private static void AddFailedTasks(IReadOnlyList<JobTaskDetail> tasks, List<RecoveryPlanReadinessFailedTask> failedTasks)
private static void AddFailedTasks(IReadOnlyList<ResilienceManagementJobTaskDetail> tasks, List<RecoveryPlanReadinessFailedTask> failedTasks)
{
foreach (JobTaskDetail task in tasks)
foreach (ResilienceManagementJobTaskDetail task in tasks)
{
string status = task.Status?.ToString() ?? string.Empty;
if (string.Equals(status, "Failed", StringComparison.OrdinalIgnoreCase))
@@ -1690,9 +1691,10 @@ public sealed class ResilienceManagementService(IAzureService azureService)
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);
SystemData: root.TryGetProperty("systemData", out JsonElement systemDataElement) ? systemDataElement.Clone() : default,
Identity: root.TryGetProperty("identity", out JsonElement identityElement) ? identityElement.Clone() : default);
public async Task<DrillInfo> CreateDrillAsync(string serviceGroup, string drill, string subscription, string region, string? resourceGroup, DrillKind drillType, DrillRbacSetupMode rbacSetupMode, string? recoveryPlan = null, string? tenant = null, CancellationToken cancellationToken = default)
public async Task<DrillInfo> CreateDrillAsync(string serviceGroup, string drill, string subscription, string region, string? resourceGroup, DrillKind drillType, DrillRbacSetupMode rbacSetupMode, DrillProperties? properties = null, string? tenant = null, CancellationToken cancellationToken = default)
{
var subscriptionId = AzureService.IsSubscriptionId(subscription)
? subscription
@@ -1703,7 +1705,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
var serviceGroupId = CreateServiceGroupResourceIdentifier(serviceGroup);
ResilienceManagementDrillCollection drills = armClient.GetResilienceManagementDrills(serviceGroupId);
var associatedIdentity = new ResilienceManagementAssociatedIdentity(ManagedServiceIdentityType.SystemAssigned);
DrillProperties properties = drillType switch
properties ??= drillType switch
{
DrillKind.Zonal => new ZonalDrillProperties(),
DrillKind.Regional => new RegionalDrillProperties(),
@@ -1723,13 +1725,8 @@ public sealed class ResilienceManagementService(IAzureService azureService)
_ => throw new ArgumentOutOfRangeException(nameof(rbacSetupMode), rbacSetupMode, "Unsupported RBAC setup mode.")
};
// The 2026-04-01-preview backend flow requires both identities even when resources do not exist yet.
properties.RecoveryPlanProperties = ArmResilienceManagementModelFactory.RecoveryPlanPropertiesOfDrill(
associatedIdentity,
string.IsNullOrWhiteSpace(recoveryPlan)
? null
: RecoveryPlanResource.CreateResourceIdentifier(serviceGroup, recoveryPlan),
recoveryPlanResourceExcludedCount: null);
// recoveryPlanId is read-only; the service links the service group's recovery plan for the drill type.
properties.RecoveryPlanProperties = new RecoveryPlanPropertiesOfDrill(associatedIdentity);
properties.MonitoringProperties = ArmResilienceManagementModelFactory.MonitoringPropertiesOfDrill(
associatedIdentity,
logAnalyticsWorkspaceId: null,
@@ -1750,7 +1747,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
return MapDrill(completedDrill.Value.Data);
}
public async Task<DrillInfo> UpdateDrillAsync(string serviceGroup, string drill, string? subscription = null, string? region = null, DrillRbacSetupMode? rbacSetupMode = null, string? recoveryPlan = null, string? tenant = null, CancellationToken cancellationToken = default)
public async Task<DrillInfo> UpdateDrillAsync(string serviceGroup, string drill, string? subscription = null, string? region = null, DrillRbacSetupMode? rbacSetupMode = null, DrillUpdateProperties? properties = null, string? tenant = null, CancellationToken cancellationToken = default)
{
var subscriptionId = subscription is null || AzureService.IsSubscriptionId(subscription)
? subscription
@@ -1760,8 +1757,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
var drillId = ResilienceManagementDrillResource.CreateResourceIdentifier(serviceGroup, drill);
ResilienceManagementDrillResource drillResource = armClient.GetResilienceManagementDrillResource(drillId);
var properties = new DrillUpdateProperties();
var associatedIdentity = new ResilienceManagementAssociatedIdentity(ManagedServiceIdentityType.SystemAssigned);
properties ??= new DrillUpdateProperties();
if (subscriptionId is not null && region is not null)
{
@@ -1773,14 +1769,6 @@ public sealed class ResilienceManagementService(IAzureService azureService)
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
@@ -1800,7 +1788,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
string? tenant = null,
CancellationToken cancellationToken = default)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, cancellationToken: cancellationToken);
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, armClientOptions: CreateArmClientOptionsWithOperationIdPolicy(), cancellationToken: cancellationToken);
var drillId = ResilienceManagementDrillResource.CreateResourceIdentifier(serviceGroup, drill);
ResilienceManagementDrillResource drillResource = armClient.GetResilienceManagementDrillResource(drillId);
@@ -1817,15 +1805,38 @@ public sealed class ResilienceManagementService(IAzureService azureService)
public async Task<DrillValidateForExecutionResult> ValidateDrillForExecutionAsync(
string serviceGroup,
string drill,
IEnumerable<string> sourceLocations,
IEnumerable<string>? sourceLocations = null,
DrillRunTask? operationName = null,
string? tenant = null,
CancellationToken cancellationToken = default)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, cancellationToken: cancellationToken);
string[]? locations = sourceLocations?.ToArray();
if (operationName != DrillRunTask.Reprotect && locations is not { Length: > 0 })
{
throw new ArgumentException("Source locations are required unless the validation operation is Reprotect.", nameof(sourceLocations));
}
if (locations is not null && (locations.Length == 0 || locations.Any(string.IsNullOrWhiteSpace)))
{
throw new ArgumentException("Source locations must contain at least one non-empty location.", nameof(sourceLocations));
}
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, armClientOptions: CreateArmClientOptionsWithOperationIdPolicy(), cancellationToken: cancellationToken);
var drillId = ResilienceManagementDrillResource.CreateResourceIdentifier(serviceGroup, drill);
ResilienceManagementDrillResource drillResource = armClient.GetResilienceManagementDrillResource(drillId);
ValidateForExecutionContent content = ArmResilienceManagementModelFactory.ValidateForExecutionContent(sourceLocations);
var properties = new ValidateForExecutionProperties();
if (operationName.HasValue)
{
properties.OperationName = operationName.Value;
}
if (locations is not null)
{
foreach (string location in locations)
{
properties.SourceLocations.Add(location);
}
}
var content = new ValidateForExecutionContent { ValidateForExecutionProperties = properties };
string operationId = Guid.NewGuid().ToString();
var operation = await drillResource.ValidateForExecutionAsync(
@@ -1844,7 +1855,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
string? tenant = null,
CancellationToken cancellationToken = default)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, cancellationToken: cancellationToken);
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, armClientOptions: CreateArmClientOptionsWithOperationIdPolicy(), cancellationToken: cancellationToken);
var drillId = ResilienceManagementDrillResource.CreateResourceIdentifier(serviceGroup, drill);
ResilienceManagementDrillResource drillResource = armClient.GetResilienceManagementDrillResource(drillId);
@@ -1871,7 +1882,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
public async Task<string> StartDrillAsync(string serviceGroup, string drill, string mode, string? tenant = null, CancellationToken cancellationToken = default)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, cancellationToken: cancellationToken);
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, armClientOptions: CreateArmClientOptionsWithOperationIdPolicy(), cancellationToken: cancellationToken);
var drillId = ResilienceManagementDrillResource.CreateResourceIdentifier(serviceGroup, drill);
ResilienceManagementDrillResource drillResource = armClient.GetResilienceManagementDrillResource(drillId);
@@ -1888,7 +1899,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
public async Task<string> EndDrillAsync(string serviceGroup, string drill, string attestation, string attestationNotes, string? tenant = null, CancellationToken cancellationToken = default)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, cancellationToken: cancellationToken);
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, armClientOptions: CreateArmClientOptionsWithOperationIdPolicy(), cancellationToken: cancellationToken);
var drillId = ResilienceManagementDrillResource.CreateResourceIdentifier(serviceGroup, drill);
ResilienceManagementDrillResource drillResource = armClient.GetResilienceManagementDrillResource(drillId);
@@ -1997,18 +2008,9 @@ public sealed class ResilienceManagementService(IAzureService azureService)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, armClientOptions: CreateArmClientOptionsWithOperationIdPolicy(), cancellationToken: cancellationToken);
var requestProperties = new FailoverRequestProperties(sourceLocations);
foreach (string resourceId in selectedResourceIds ?? [])
{
requestProperties.SelectedResourceIds.Add(new ResourceIdentifier(resourceId));
}
var failoverProperties = new ResilienceManagementFailoverContent(FailoverDirectionTypes.FromSpecificLocations)
{
FailoverRequestProperties = requestProperties
};
var failoverProperties = CreateFailoverRequestContent(sourceLocations.ToList(), selectedResourceIds?.ToList(), null);
var content = new DrillRunFailoverContent(
autoFailover ? AutoFailover.Enable : AutoFailover.Disable,
autoFailover ? DrillRunAutoFailover.Enable : DrillRunAutoFailover.Disable,
failoverProperties);
var drillRunId = DrillRunResource.CreateResourceIdentifier(serviceGroup, drill, drillRun);
DrillRunResource drillRunResource = armClient.GetDrillRunResource(drillRunId);
@@ -2038,7 +2040,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
var drillRunId = DrillRunResource.CreateResourceIdentifier(serviceGroup, drill, drillRun);
DrillRunResource drillRunResource = armClient.GetDrillRunResource(drillRunId);
var content = new MarkAsCompleteContent(new DrillRunSubtasks(stage));
var content = new MarkAsCompleteContent(new DrillRunStage(stage));
string operationId = Guid.NewGuid().ToString();
var operation = await drillRunResource.MarkAsCompleteAsync(WaitUntil.Started, operationId, content, cancellationToken);
@@ -2046,7 +2048,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
return new DrillRunMarkCompleteResult(operationId, operation.HasCompleted);
}
public async Task<string> ReprotectDrillRunAsync(string serviceGroup, string drill, string drillRun, string? tenant = null, CancellationToken cancellationToken = default)
public async Task<string> ReprotectDrillRunAsync(string serviceGroup, string drill, string drillRun, IReadOnlyList<string>? selectedResourceIds = null, string? tenant = null, CancellationToken cancellationToken = default)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, armClientOptions: CreateArmClientOptionsWithOperationIdPolicy(), cancellationToken: cancellationToken);
@@ -2054,11 +2056,23 @@ public sealed class ResilienceManagementService(IAzureService azureService)
DrillRunResource drillRunResource = armClient.GetDrillRunResource(drillRunId);
string operationId = Guid.NewGuid().ToString();
await drillRunResource.ReprotectAsync(WaitUntil.Started, operationId, cancellationToken);
DrillRunReprotectContent content = CreateDrillRunReprotectContent(selectedResourceIds);
await drillRunResource.ReprotectAsync(WaitUntil.Started, operationId, content, cancellationToken);
return operationId;
}
// The service accepts a reprotect without a request body but never processes it, so always send a JSON body.
internal static DrillRunReprotectContent CreateDrillRunReprotectContent(IReadOnlyList<string>? selectedResourceIds)
{
var content = new DrillRunReprotectContent();
foreach (string resourceId in selectedResourceIds ?? [])
{
content.ReprotectRequestSelectedResourceIds.Add(new ResourceIdentifier(resourceId));
}
return content;
}
public async Task<IEnumerable<ResourceSummary>> ListDrillRunResourcesAsync(string serviceGroup, string drill, string drillRun, string? tenant = null, CancellationToken cancellationToken = default)
{
ArmClient armClient = await CreateArmClientAsync(tenantIdOrName: tenant, cancellationToken: cancellationToken);
@@ -2133,12 +2147,9 @@ public sealed class ResilienceManagementService(IAzureService azureService)
{
Properties = new UsagePlanProperties
{
PlanType = planType switch
{
UsagePlanKind.Standard => UsagePlanType.Standard,
UsagePlanKind.Basic => UsagePlanType.Basic,
_ => throw new ArgumentOutOfRangeException(nameof(planType), planType, "Unsupported plan type.")
}
PlanType = planType == UsagePlanKind.Standard
? UsagePlanType.Standard
: throw new ArgumentOutOfRangeException(nameof(planType), planType, "Unsupported plan type.")
}
};
@@ -2190,7 +2201,7 @@ public sealed class ResilienceManagementService(IAzureService azureService)
var serviceGroupId = new ResourceIdentifier($"/providers/Microsoft.Management/serviceGroups/{serviceGroup}");
var enrollmentData = new UsagePlanEnrollmentData
{
Properties = new EnrollmentProperties(serviceGroupId)
Properties = new UsagePlanEnrollmentProperties(serviceGroupId)
};
ArmOperation<UsagePlanEnrollmentResource> operation = await enrollments.CreateOrUpdateAsync(WaitUntil.Started, enrollment, enrollmentData, cancellationToken);
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// cspell:words slis
using System.ClientModel.Primitives;
using System.Text.Json;
using Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Xunit;
namespace Azure.Mcp.Tools.ResilienceManagement.Tests.Drills;
public sealed class DrillConfigurationTests
{
[Fact]
public void Create_EnforcesExactPayloadLimit()
{
const int limit = 1024 * 1024;
const string goal = """{"identity":{"type":"SystemAssigned"}}""";
string boundary = goal.PadRight(limit);
Assert.NotNull(DrillConfiguration.Create(DrillKind.Zonal, boundary, null, null).GoalAssignmentProperties);
ArgumentException error = Assert.Throws<ArgumentException>(() =>
DrillConfiguration.Create(DrillKind.Zonal, boundary + " ", null, null));
Assert.Contains("must not exceed 1 MB", error.Message);
}
[Fact]
public void Update_AcceptsTwoDistinctSliSelections()
{
const string prefix = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/providers/Microsoft.Monitor/slis/";
string payload = $$"""
{"slis":[{"sliId":"{{prefix}}availability","type":"Availability"},{"sliId":"{{prefix}}latency","type":"Latency"}]}
""";
var properties = DrillConfiguration.Update(null, null, payload);
using JsonDocument document = JsonDocument.Parse(ModelReaderWriter.Write(properties, new ModelReaderWriterOptions("W")));
Assert.Equal("sliMonitoringProperties", Assert.Single(document.RootElement.EnumerateObject()).Name);
Assert.Equal(2, document.RootElement.GetProperty("sliMonitoringProperties").GetProperty("slis").GetArrayLength());
}
[Theory]
[InlineData("null", null, "healthModelMonitoringProperties")]
[InlineData(null, "null", "sliMonitoringProperties")]
public void Update_SerializesOnlyExplicitMonitoringClear(string? healthModel, string? sli, string expectedProperty)
{
var properties = DrillConfiguration.Update(null, healthModel, sli);
using JsonDocument document = JsonDocument.Parse(ModelReaderWriter.Write(properties, new ModelReaderWriterOptions("W")));
JsonProperty property = Assert.Single(document.RootElement.EnumerateObject());
Assert.Equal(expectedProperty, property.Name);
Assert.Equal(JsonValueKind.Null, property.Value.ValueKind);
}
[Fact]
public void Create_AcceptsServiceGroupScopedSli()
{
const string id = "/providers/Microsoft.Management/serviceGroups/fixture/providers/Microsoft.Monitor/slis/availability";
string payload = $$"""
{"identity":{"type":"SystemAssigned"},"slis":[{"sliId":"{{id}}","type":"Availability"}]}
""";
var properties = DrillConfiguration.Create(DrillKind.Zonal, null, null, payload);
using JsonDocument document = JsonDocument.Parse(ModelReaderWriter.Write(properties, new ModelReaderWriterOptions("W")));
Assert.Equal(id, document.RootElement.GetProperty("sliMonitoringProperties").GetProperty("slis")[0].GetProperty("sliId").GetString());
}
[Theory]
[InlineData("null")]
[InlineData("[]")]
[InlineData("{}")]
[InlineData("{")]
[InlineData("""{"identity":{"type":"Unknown"}}""")]
[InlineData("""{"identity":{"type":"UserAssigned"}}""")]
[InlineData("""{"identity":{"type":"SystemAssigned"},"goalAssignmentId":"readonly"}""")]
[InlineData("""{"identity":{"type":"SystemAssigned"},"identity":{"type":"None"}}""")]
public void Create_RejectsInvalidGoalConfiguration(string payload)
{
Assert.Throws<ArgumentException>(() => DrillConfiguration.Create(DrillKind.Zonal, payload, null, null));
}
[Theory]
[InlineData("null")]
[InlineData("""{"identity":{"type":"SystemAssigned"}}""")]
[InlineData("""{"identity":{"type":"SystemAssigned"},"healthModelId":"not-an-arm-id"}""")]
[InlineData("""{"healthModelId":"/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/providers/Microsoft.CloudHealth/healthmodels/health"}""")]
public void Create_RejectsInvalidHealthModelConfiguration(string payload)
{
Assert.Throws<ArgumentException>(() => DrillConfiguration.Create(DrillKind.Zonal, null, payload, null));
}
[Theory]
[InlineData("""{"slis":[]}""")]
[InlineData("""{"slis":[{}]}""")]
[InlineData("""{"slis":[{"sliId":"bad","type":"Availability"}]}""")]
[InlineData("""{"slis":[{}, {}, {}]}""")]
[InlineData("""{"slis":null}""")]
[InlineData("""{"identity":null}""")]
public void Update_RejectsInvalidSliConfiguration(string payload)
{
Assert.Throws<ArgumentException>(() => DrillConfiguration.Update(null, null, payload));
}
[Theory]
[InlineData("sli-one", "Availability", "sli-two", "Availability")]
[InlineData("sli-one", "Availability", "SLI-ONE", "Latency")]
[InlineData("sli-one", "Other", "sli-two", "Latency")]
public void Update_RejectsDuplicateOrInvalidSliSelections(string firstId, string firstType, string secondId, string secondType)
{
const string prefix = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/providers/Microsoft.Monitor/slis/";
string payload = $$"""
{"slis":[{"sliId":"{{prefix + firstId}}","type":"{{firstType}}"},{"sliId":"{{prefix + secondId}}","type":"{{secondType}}"}]}
""";
Assert.Throws<ArgumentException>(() => DrillConfiguration.Update(null, null, payload));
}
[Fact]
public void Update_RejectsClearingGoalAssignmentProperties()
{
Assert.Throws<ArgumentException>(() => DrillConfiguration.Update("null", null, null));
}
[Fact]
public void Create_AcceptsUserAssignedIdentity()
{
const string id = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/identity";
string goal = $$"""{"identity":{"type":"UserAssigned","userAssignedIdentity":"{{id}}"} }""";
var properties = DrillConfiguration.Create(DrillKind.Zonal, goal, null, null);
Assert.Equal(id, properties.GoalAssignmentProperties.Identity.UserAssignedIdentity.ToString());
}
}
@@ -1,11 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// cspell:words slis
using System.Net;
using Azure.Mcp.Tools.ResilienceManagement.Commands;
using Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Azure.Mcp.Tools.ResilienceManagement.Services;
using Azure.ResourceManager.ResilienceManagement.Models;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
@@ -85,7 +88,7 @@ public sealed class DrillCreateCommandTests : CommandUnitTestsBase<DrillCreateCo
resourceGroup,
DrillKind.Zonal,
DrillRbacSetupMode.Manual,
null,
Arg.Any<DrillProperties>(),
null,
Arg.Any<CancellationToken>());
}
@@ -123,7 +126,7 @@ public sealed class DrillCreateCommandTests : CommandUnitTestsBase<DrillCreateCo
"rg",
DrillKind.Zonal,
DrillRbacSetupMode.AutomatedBuiltinRoles,
null,
Arg.Any<DrillProperties>(),
null,
Arg.Any<CancellationToken>());
}
@@ -143,7 +146,7 @@ public sealed class DrillCreateCommandTests : CommandUnitTestsBase<DrillCreateCo
Arg.Any<string?>(),
Arg.Any<DrillKind>(),
Arg.Any<DrillRbacSetupMode>(),
Arg.Any<string?>(),
Arg.Any<DrillProperties?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new RequestFailedException((int)status, "Sensitive provider details"));
@@ -165,9 +168,39 @@ public sealed class DrillCreateCommandTests : CommandUnitTestsBase<DrillCreateCo
Arg.Any<string?>(),
Arg.Any<DrillKind>(),
Arg.Any<DrillRbacSetupMode>(),
Arg.Any<string?>(),
Arg.Any<DrillProperties?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(new DrillInfo("id1", "drill1"));
}
[Fact]
public async Task ExecuteAsync_ForwardsGaConfiguration()
{
ConfigureCreatedDrill();
const string healthId = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/providers/Microsoft.CloudHealth/healthmodels/health";
const string sliId = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg/providers/Microsoft.Monitor/slis/sli";
var response = await ExecuteCommandAsync([.. ValidArgs.Split(' '),
"--goal-assignment-properties", """{"identity":{"type":"SystemAssigned"}}""",
"--health-model-monitoring-properties", $$"""{"identity":{"type":"SystemAssigned"},"healthModelId":"{{healthId}}"}""",
"--sli-monitoring-properties", $$"""{"identity":{"type":"SystemAssigned"},"slis":[{"sliId":"{{sliId}}","type":"Availability"}]}"""]);
Assert.Equal(HttpStatusCode.OK, response.Status);
var properties = Assert.IsAssignableFrom<DrillProperties>(Assert.Single(Service.ReceivedCalls()).GetArguments()[7]);
Assert.Equal("SystemAssigned", properties.GoalAssignmentProperties.Identity.Type.ToString());
Assert.Equal(healthId, properties.HealthModelMonitoringProperties.HealthModelId.ToString());
Assert.Equal(sliId, Assert.Single(properties.SliMonitoringProperties.Slis).SliId.ToString());
}
[Theory]
[InlineData("--goal-assignment-properties", """{"goalAssignmentId":"read-only"}""")]
[InlineData("--health-model-monitoring-properties", "null")]
[InlineData("--sli-monitoring-properties", """{"identity":{"type":"SystemAssigned"},"slis":[]}""")]
public async Task ExecuteAsync_RejectsInvalidGaConfiguration(string option, string payload)
{
var response = await ExecuteCommandAsync([.. ValidArgs.Split(' '), option, payload]);
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Empty(Service.ReceivedCalls());
}
}
@@ -1,11 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// cspell:words slis
using System.ClientModel.Primitives;
using System.Net;
using System.Text.Json;
using Azure.Mcp.Tools.ResilienceManagement.Commands;
using Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Azure.Mcp.Tools.ResilienceManagement.Services;
using Azure.ResourceManager.ResilienceManagement.Models;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
@@ -61,7 +66,7 @@ public sealed class DrillUpdateCommandTests : CommandUnitTestsBase<DrillUpdateCo
null,
null,
DrillRbacSetupMode.Manual,
null,
Arg.Any<DrillUpdateProperties>(),
null,
Arg.Any<CancellationToken>());
}
@@ -79,7 +84,7 @@ public sealed class DrillUpdateCommandTests : CommandUnitTestsBase<DrillUpdateCo
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<DrillRbacSetupMode?>(),
Arg.Any<string?>(),
Arg.Any<DrillUpdateProperties?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new RequestFailedException((int)status, "Sensitive provider details"));
@@ -99,9 +104,36 @@ public sealed class DrillUpdateCommandTests : CommandUnitTestsBase<DrillUpdateCo
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<DrillRbacSetupMode?>(),
Arg.Any<string?>(),
Arg.Any<DrillUpdateProperties?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(new DrillInfo("id1", "drill1"));
}
[Theory]
[InlineData("--health-model-monitoring-properties", "healthModelMonitoringProperties")]
[InlineData("--sli-monitoring-properties", "sliMonitoringProperties")]
public async Task ExecuteAsync_ForwardsExplicitNullWithoutOtherUpdates(string option, string propertyName)
{
ConfigureUpdatedDrill();
var response = await ExecuteCommandAsync("--service-group", "sg1", "--drill", "drill1", option, "null");
Assert.Equal(HttpStatusCode.OK, response.Status);
var properties = Assert.IsAssignableFrom<DrillUpdateProperties>(Assert.Single(Service.ReceivedCalls()).GetArguments()[5]);
using JsonDocument document = JsonDocument.Parse(ModelReaderWriter.Write(properties, new ModelReaderWriterOptions("W")));
Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty(propertyName).ValueKind);
Assert.Single(document.RootElement.EnumerateObject());
}
[Theory]
[InlineData("--goal-assignment-properties", "null")]
[InlineData("--health-model-monitoring-properties", """{"healthModelId":"invalid"}""")]
[InlineData("--sli-monitoring-properties", """{"slis":[]}""")]
public async Task ExecuteAsync_RejectsInvalidGaConfiguration(string option, string payload)
{
var response = await ExecuteCommandAsync("--service-group", "sg1", "--drill", "drill1", option, payload);
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Empty(Service.ReceivedCalls());
}
}
@@ -7,6 +7,7 @@ using Azure.Mcp.Tools.ResilienceManagement.Commands;
using Azure.Mcp.Tools.ResilienceManagement.Commands.Drills;
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;
@@ -21,6 +22,67 @@ public class DrillValidateForExecutionCommandTests
private const string ServiceGroup = "sg1";
private const string Drill = "drill1";
[Theory]
[InlineData("Failover")]
[InlineData("Reprotect")]
[InlineData("FailoverReverse")]
[InlineData("ReprotectReverse")]
public async Task ExecuteAsync_ForwardsOperationWithSourceLocations(string operation)
{
Service.ValidateDrillForExecutionAsync(ServiceGroup, Drill,
Arg.Is<IEnumerable<string>>(locations => locations.SequenceEqual(new[] { "westus2-az1" })),
new DrillRunTask(operation), null, Arg.Any<CancellationToken>())
.Returns(new DrillValidateForExecutionResult("ga-operation", false));
var response = await ExecuteCommandAsync("--service-group", ServiceGroup, "--drill", Drill,
"--operation-name", operation.ToLowerInvariant(), "--source-locations", "westus2-az1");
Assert.Equal(HttpStatusCode.OK, response.Status);
var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.DrillValidateForExecutionCommandResult);
Assert.Equal("ga-operation", result.Validation.OperationId);
}
[Fact]
public async Task ExecuteAsync_AllowsReprotectWithoutSourceLocations()
{
Service.ValidateDrillForExecutionAsync(ServiceGroup, Drill, null, DrillRunTask.Reprotect, null, Arg.Any<CancellationToken>())
.Returns(new DrillValidateForExecutionResult("ga-operation", false));
var response = await ExecuteCommandAsync("--service-group", ServiceGroup, "--drill", Drill,
"--operation-name", "reprotect");
var result = ValidateAndDeserializeResponse(response, ResilienceManagementJsonContext.Default.DrillValidateForExecutionCommandResult);
Assert.Equal("ga-operation", result.Validation.OperationId);
}
[Theory]
[InlineData(null)]
[InlineData("Failover")]
[InlineData("FailoverReverse")]
[InlineData("ReprotectReverse")]
public async Task ExecuteAsync_RejectsMissingRequiredSourceLocations(string? operation)
{
string[] args = operation is null
? ["--service-group", ServiceGroup, "--drill", Drill]
: ["--service-group", ServiceGroup, "--drill", Drill, "--operation-name", operation];
var response = await ExecuteCommandAsync(args);
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Contains("--source-locations is required", response.Message);
Assert.Empty(Service.ReceivedCalls());
}
[Theory]
[InlineData("--operation-name", "Delete")]
[InlineData("--operation-name", "999")]
[InlineData("--source-locations", "")]
public async Task ExecuteAsync_RejectsInvalidOptionalSelectors(string option, string value)
{
var response = await ExecuteCommandAsync("--service-group", ServiceGroup, "--drill", Drill, option, value);
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Empty(Service.ReceivedCalls());
}
[Fact]
public void Constructor_InitializesCommandCorrectly()
{
@@ -43,7 +105,8 @@ public class DrillValidateForExecutionCommandTests
Service.ValidateDrillForExecutionAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<IEnumerable<string>>(),
Arg.Any<IEnumerable<string>?>(),
Arg.Any<DrillRunTask?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Returns(new DrillValidateForExecutionResult("operation1", false));
@@ -61,6 +124,7 @@ public class DrillValidateForExecutionCommandTests
ServiceGroup,
Drill,
Arg.Is<IEnumerable<string>>(locations => locations.SequenceEqual(new[] { "eastus-az1", "westus-az2" })),
null,
"tenant1",
Arg.Any<CancellationToken>())
.Returns(new DrillValidateForExecutionResult("operation1", false));
@@ -103,6 +167,7 @@ public class DrillValidateForExecutionCommandTests
ServiceGroup,
Drill,
Arg.Any<IEnumerable<string>>(),
Arg.Any<DrillRunTask?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Test error"));
@@ -123,6 +188,7 @@ public class DrillValidateForExecutionCommandTests
ServiceGroup,
Drill,
Arg.Any<IEnumerable<string>>(),
Arg.Any<DrillRunTask?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new RequestFailedException((int)HttpStatusCode.NotFound, "Not found"));
@@ -21,7 +21,68 @@ public sealed class DrillAddOrUpdateResourcesCommandTests
{
private const string ServiceGroup = "sg1";
private const string Drill = "drill1";
private const string IncludeJson = "[{\"id\":\"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1\"}]";
private const string DrillResourceId = "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/22222222-2222-2222-2222-222222222222";
private const string IncludeJson = "[{\"id\":\"" + DrillResourceId + "\"}]";
[Theory]
[InlineData("--include-resources", "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1")]
[InlineData("--update-resources", "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1")]
[InlineData("--include-resources", "/providers/Microsoft.Management/serviceGroups/other/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/22222222-2222-2222-2222-222222222222")]
[InlineData("--update-resources", "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/other/drillResources/22222222-2222-2222-2222-222222222222")]
[InlineData("--include-resources", "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/resource1")]
[InlineData("--include-resources", "not-an-arm-id")]
public async Task ExecuteAsync_RejectsWrongResourceIdentifiers(string option, string id)
{
var response = await ExecuteCommandAsync(
"--service-group", ServiceGroup, "--drill", Drill, "--fault-duration-minutes", "1",
option, $"[{{\"id\":\"{id}\"}}]");
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Contains("drill-resource IDs", response.Message);
await Service.DidNotReceiveWithAnyArgs().AddOrUpdateDrillResourcesAsync(default!, default!, default!, default, TestContext.Current.CancellationToken);
}
[Theory]
[InlineData("[\"/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1\"]")]
[InlineData("[\"/providers/Microsoft.Management/serviceGroups/other/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/22222222-2222-2222-2222-222222222222\"]")]
[InlineData("[\"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/other/drillResources/22222222-2222-2222-2222-222222222222\"]")]
[InlineData("[\"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/resource1\"]")]
[InlineData("[\"/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/22222222-2222-2222-2222-222222222222\"]")]
[InlineData("[\"not-an-arm-id\"]")]
[InlineData("[\"\"]")]
[InlineData("[42]")]
public async Task ExecuteAsync_RejectsWrongExcludeIdentifiers(string excludeJson)
{
var response = await ExecuteCommandAsync(
"--service-group", ServiceGroup, "--drill", Drill, "--fault-duration-minutes", "1",
"--exclude-resources", excludeJson);
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Contains("drill-resource ID", response.Message);
await Service.DidNotReceiveWithAnyArgs().AddOrUpdateDrillResourcesAsync(default!, default!, default!, default, TestContext.Current.CancellationToken);
}
[Fact]
public async Task ExecuteAsync_AcceptsExcludedDrillResourceIdsAndPreservesThem()
{
const string excludedId = "/providers/Microsoft.Management/serviceGroups/SG1/providers/Microsoft.AzureResilienceManagement/drills/DRILL1/drillResources/33333333-3333-3333-3333-333333333333";
Service.AddOrUpdateDrillResourcesAsync(ServiceGroup, Drill, Arg.Any<AddOrUpdateResourcesContent>(), null, Arg.Any<CancellationToken>())
.Returns(new DrillAddOrUpdateResourcesResult("operation1", false));
var response = await ExecuteCommandAsync(
"--service-group", ServiceGroup, "--drill", Drill, "--fault-duration-minutes", "1",
"--exclude-resources", $"[\"{excludedId}\"]");
Assert.Equal(HttpStatusCode.OK, response.Status);
await Service.Received(1).AddOrUpdateDrillResourcesAsync(
ServiceGroup,
Drill,
Arg.Is<AddOrUpdateResourcesContent>(content =>
content.ResourceLists.ExcludeResources.Count == 1 &&
content.ResourceLists.ExcludeResources[0].ToString() == excludedId),
null,
Arg.Any<CancellationToken>());
}
[Fact]
public void Constructor_InitializesCommandCorrectly()
@@ -85,4 +85,33 @@ public class DrillResourceGetCommandTests : CommandUnitTestsBase<DrillResourceGe
Assert.Equal(HttpStatusCode.InternalServerError, response.Status);
Assert.StartsWith(expectedError, response.Message);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ExecuteAsync_PreservesGaResponseValidationError(bool getSingle)
{
var error = new RequestFailedException(
412, "Missing required property: drillType.", "HttpResponsePayloadAPISpecValidationFailed", null);
if (getSingle)
{
Service.GetDrillResourceAsync(ServiceGroup, Drill, "target1", Arg.Any<string?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(error);
}
else
{
Service.ListDrillResourcesAsync(ServiceGroup, Drill, Arg.Any<string?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(error);
}
var args = new List<string> { "--service-group", ServiceGroup, "--drill", Drill };
if (getSingle)
{
args.AddRange(["--name", "target1"]);
}
var response = await ExecuteCommandAsync([.. args]);
Assert.Equal(HttpStatusCode.PreconditionFailed, response.Status);
Assert.Contains("Missing required property: drillType.", response.Message);
}
}
@@ -18,9 +18,50 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase<DrillRun
private const string Drill = "drill1";
private const string DrillRun = "run1";
private const string SourceLocation = "eastus-az1";
private const string SelectedResourceId = "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/22222222-2222-2222-2222-222222222222";
private const string SelectedResourceId = "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/22222222-2222-2222-2222-222222222222";
private const string OperationId = "11111111-1111-1111-1111-111111111111";
[Theory]
[InlineData()]
[InlineData("--auto-failover", "true")]
[InlineData("--auto-failover", "false")]
[InlineData("--selected-resource-ids", SelectedResourceId)]
public async Task ExecuteAsync_RejectsMissingSourceLocations(params string[] failoverInputs)
{
var response = await ExecuteCommandAsync(["--service-group", ServiceGroup, "--drill", Drill,
"--drill-run", DrillRun, .. failoverInputs]);
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Empty(Service.ReceivedCalls());
}
[Fact]
public async Task ExecuteAsync_DefaultsAutomaticFailoverToFalse()
{
Service.FailoverDrillRunAsync(ServiceGroup, Drill, DrillRun, Arg.Any<IEnumerable<string>>(), null, false, null, Arg.Any<CancellationToken>())
.Returns(OperationId);
var response = await ExecuteCommandAsync("--service-group", ServiceGroup, "--drill", Drill,
"--drill-run", DrillRun, "--source-locations", SourceLocation);
Assert.Equal(HttpStatusCode.OK, response.Status);
await Service.Received(1).FailoverDrillRunAsync(ServiceGroup, Drill, DrillRun,
Arg.Is<IEnumerable<string>>(locations => locations.SequenceEqual(new[] { SourceLocation })),
null, false, null, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_PreservesAutomaticFailoverFlag()
{
Service.FailoverDrillRunAsync(ServiceGroup, Drill, DrillRun, Arg.Any<IEnumerable<string>>(), null, true, null, Arg.Any<CancellationToken>())
.Returns(OperationId);
var response = await ExecuteCommandAsync("--service-group", ServiceGroup, "--drill", Drill,
"--drill-run", DrillRun, "--source-locations", SourceLocation, "--auto-failover");
Assert.Equal(HttpStatusCode.OK, response.Status);
Assert.Equal(OperationId, ValidateAndDeserializeResponse(response,
ResilienceManagementJsonContext.Default.DrillRunFailoverCommandResult).OperationId);
}
[Fact]
public void Constructor_InitializesCommandCorrectly()
{
@@ -34,7 +75,6 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase<DrillRun
[InlineData()]
[InlineData("--service-group", ServiceGroup)]
[InlineData("--service-group", ServiceGroup, "--drill", Drill)]
[InlineData("--service-group", ServiceGroup, "--drill", Drill, "--drill-run", DrillRun)]
public async Task ExecuteAsync_RejectsMissingRequiredOptions(params string[] args)
{
var response = await ExecuteCommandAsync(args);
@@ -91,6 +131,12 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase<DrillRun
[InlineData("/providers/Microsoft.Management/serviceGroups/other/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/resource1")]
[InlineData("/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryResources/resource1")]
[InlineData("/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/virtualMachines/vm1")]
[InlineData("/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/22222222-2222-2222-2222-222222222222")]
[InlineData("/providers/Microsoft.Management/serviceGroups/other/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/22222222-2222-2222-2222-222222222222")]
[InlineData("/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/other/drillResources/22222222-2222-2222-2222-222222222222")]
[InlineData("/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drills/drill1/drillResources/resource1")]
[InlineData("/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/drillResources/22222222-2222-2222-2222-222222222222")]
[InlineData("/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1")]
public async Task ExecuteAsync_RejectsInvalidSelectedResourceId(string selectedResourceId)
{
var response = await ExecuteCommandAsync(
@@ -101,7 +147,7 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase<DrillRun
"--selected-resource-ids", selectedResourceId);
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Contains("full recovery-resource ID under the requested service group", response.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("full drill-resource ID under the requested service group and drill", response.Message, StringComparison.OrdinalIgnoreCase);
await Service.DidNotReceive().FailoverDrillRunAsync(
Arg.Any<string>(),
Arg.Any<string>(),
@@ -113,8 +159,10 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase<DrillRun
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_StartsFailoverAndReturnsAcceptedResult()
[Theory]
[InlineData(SelectedResourceId)]
[InlineData("/providers/Microsoft.Management/serviceGroups/SG1/providers/Microsoft.AzureResilienceManagement/drills/DRILL1/drillResources/22222222-2222-2222-2222-222222222222")]
public async Task ExecuteAsync_StartsFailoverAndReturnsAcceptedResult(string selectedResourceId)
{
Service.FailoverDrillRunAsync(
ServiceGroup,
@@ -133,7 +181,7 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase<DrillRun
"--drill-run", DrillRun,
"--source-locations", SourceLocation,
"--source-locations", "westus2-az2",
"--selected-resource-ids", SelectedResourceId,
"--selected-resource-ids", selectedResourceId,
"--auto-failover", "true");
Assert.True(response.Status == HttpStatusCode.OK, response.Message);
@@ -146,7 +194,7 @@ public sealed class DrillRunFailoverCommandTests : CommandUnitTestsBase<DrillRun
Drill,
DrillRun,
Arg.Is<IEnumerable<string>>(locations => locations.SequenceEqual(new[] { SourceLocation, "westus2-az2" })),
Arg.Is<IEnumerable<string>>(resourceIds => resourceIds.SequenceEqual(new[] { SelectedResourceId })),
Arg.Is<IEnumerable<string>>(resourceIds => resourceIds.SequenceEqual(new[] { selectedResourceId })),
true,
null,
Arg.Any<CancellationToken>());
@@ -19,6 +19,33 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase<DrillRu
private const string DrillRun = "run1";
private const string OperationId = "11111111-1111-1111-1111-111111111111";
[Fact]
public async Task ExecuteAsync_ForwardsSelectedRecoveryResources()
{
const string id = "/providers/Microsoft.Management/serviceGroups/sg1/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/22222222-2222-2222-2222-222222222222";
Service.ReprotectDrillRunAsync(ServiceGroup, Drill, DrillRun,
Arg.Is<IReadOnlyList<string>>(ids => ids.SequenceEqual(new[] { id })), null, Arg.Any<CancellationToken>()).Returns(OperationId);
var response = await ExecuteCommandAsync("--service-group", ServiceGroup, "--drill", Drill,
"--drill-run", DrillRun, "--selected-resource-ids", id);
Assert.Equal(HttpStatusCode.OK, response.Status);
Assert.Equal(OperationId, ValidateAndDeserializeResponse(response,
ResilienceManagementJsonContext.Default.DrillRunReprotectCommandResult).OperationId);
}
[Theory]
[InlineData("invalid")]
[InlineData("/providers/Microsoft.Management/serviceGroups/other/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan1/recoveryResources/22222222-2222-2222-2222-222222222222")]
public async Task ExecuteAsync_RejectsInvalidSelectedRecoveryResources(string id)
{
var response = await ExecuteCommandAsync("--service-group", ServiceGroup, "--drill", Drill,
"--drill-run", DrillRun, "--selected-resource-ids", id);
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Empty(Service.ReceivedCalls());
}
[Fact]
public void Constructor_InitializesCommandCorrectly()
{
@@ -55,6 +82,7 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase<DrillRu
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<IReadOnlyList<string>?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>());
}
@@ -67,6 +95,7 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase<DrillRu
Drill,
DrillRun,
null,
null,
Arg.Any<CancellationToken>())
.Returns(OperationId);
@@ -84,6 +113,7 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase<DrillRu
Drill,
DrillRun,
null,
null,
Arg.Any<CancellationToken>());
}
@@ -99,6 +129,7 @@ public sealed class DrillRunReprotectCommandTests : CommandUnitTestsBase<DrillRu
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<IReadOnlyList<string>?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new RequestFailedException((int)status, providerDetails));
@@ -44,7 +44,7 @@ public sealed class RecoveryPlanValidateForOperationCommandTests : CommandUnitTe
Service.ValidateRecoveryPlanForOperationAsync(
"sg1",
"plan1",
Arg.Is<RecoveryOperationNames>(value => value.ToString() == operationName),
Arg.Is<RecoveryOperationName>(value => value.ToString() == operationName),
null,
Arg.Any<CancellationToken>())
.Returns(expected);
@@ -86,7 +86,7 @@ public sealed class RecoveryPlanValidateForOperationCommandTests : CommandUnitTe
Service.ValidateRecoveryPlanForOperationAsync(
"sg1",
"plan1",
Arg.Is<RecoveryOperationNames>(value => value == RecoveryOperationNames.Reprotect),
Arg.Is<RecoveryOperationName>(value => value == RecoveryOperationName.Reprotect),
null,
Arg.Any<CancellationToken>())
.Returns(expected);
@@ -114,7 +114,7 @@ public sealed class RecoveryPlanValidateForOperationCommandTests : CommandUnitTe
Service.ValidateRecoveryPlanForOperationAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<RecoveryOperationNames>(),
Arg.Any<RecoveryOperationName>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new RequestFailedException((int)status, providerDetails));
@@ -136,7 +136,7 @@ public sealed class RecoveryPlanValidateForOperationCommandTests : CommandUnitTe
Service.ValidateRecoveryPlanForOperationAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<RecoveryOperationNames>(),
Arg.Any<RecoveryOperationName>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new TimeoutException(internalDetails));
@@ -0,0 +1,166 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Microsoft.Mcp.Tests.Client;
using Microsoft.Mcp.Tests.Client.Helpers;
using Microsoft.Mcp.Tests.Generated.Models;
using Xunit;
namespace Azure.Mcp.Tools.ResilienceManagement.Tests;
public abstract class ResilienceManagementRecordedTestsBase(
ITestOutputHelper output,
TestProxyFixture fixture,
LiveServerFixture liveServerFixture)
: RecordedCommandTestsBase(output, fixture, liveServerFixture)
{
// Preserve LRO Location paths during recording; replacing the entire header breaks polling playback.
public override List<string> DisabledDefaultSanitizers =>
[
.. base.DisabledDefaultSanitizers,
"AZSDK2003"
];
public override List<HeaderRegexSanitizer> HeaderRegexSanitizers =>
[
.. base.HeaderRegexSanitizers,
new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("x-ms-operation-identifier")
{
Value = "sanitized"
}),
new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("operation-id")
{
Value = "sanitized"
}),
new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("Location")
{
Regex = "([?&](?:t|c|s|h)=)(?<value>[^&]+)",
GroupForReplace = "value",
Value = "sanitized"
}),
new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("Azure-AsyncOperation")
{
Regex = "([?&](?:t|c|s|h)=)(?<value>[^&]+)",
GroupForReplace = "value",
Value = "sanitized"
})
];
public override List<GeneralRegexSanitizer> GeneralRegexSanitizers { get; } =
[
new GeneralRegexSanitizer(new GeneralRegexSanitizerBody
{
Regex = @"(?i)[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}",
Value = "sanitized@example.com"
}),
new GeneralRegexSanitizer(new GeneralRegexSanitizerBody
{
Regex = @"(?i)(?:object id|client) '(?<id>[a-f0-9-]{36})'",
GroupForReplace = "id",
Value = "00000000-0000-0000-0000-000000000000"
}),
new GeneralRegexSanitizer(new GeneralRegexSanitizerBody
{
Regex = @"(?i)/resourcegroups/(?<name>[^/""\\?]+)",
GroupForReplace = "name",
Value = "Sanitized"
}),
new GeneralRegexSanitizer(new GeneralRegexSanitizerBody
{
Regex = @"(?i)resource group '(?<name>[^']+)'",
GroupForReplace = "name",
Value = "Sanitized"
})
];
public override string RegisterOrRetrieveDeploymentOutputVariable(string name, string deploymentOutputName)
{
var value = base.RegisterOrRetrieveDeploymentOutputVariable(name, deploymentOutputName);
if (TestMode != Microsoft.Mcp.Tests.Helpers.TestMode.Record)
{
return value;
}
if (IsResourceGroupVariable(name) ||
value.Equals(Settings.ResourceGroupName, StringComparison.OrdinalIgnoreCase))
{
TestVariables[name] = "Sanitized";
}
else if (value.StartsWith('/'))
{
// The proxy does not apply HTTP sanitizers to saved playback variables.
var segments = value.Split('/');
for (int i = 0; i < segments.Length - 1; i++)
{
if (segments[i].Equals("subscriptions", StringComparison.OrdinalIgnoreCase))
{
segments[++i] = Guid.Empty.ToString();
}
else if (segments[i].Equals("resourceGroups", StringComparison.OrdinalIgnoreCase))
{
segments[++i] = "Sanitized";
}
}
TestVariables[name] = string.Join('/', segments);
}
return value;
}
public override string RegisterOrRetrieveVariable(string name, string value)
{
var registeredValue = base.RegisterOrRetrieveVariable(name, value);
if (TestMode == Microsoft.Mcp.Tests.Helpers.TestMode.Record &&
(IsResourceGroupVariable(name) ||
value.Equals(Settings.ResourceGroupName, StringComparison.OrdinalIgnoreCase)))
{
TestVariables[name] = "Sanitized";
}
return registeredValue;
}
private static bool IsResourceGroupVariable(string name)
=> name.Contains("resourceGroup", StringComparison.OrdinalIgnoreCase) ||
name.Contains("assetGroup", StringComparison.OrdinalIgnoreCase);
public override List<UriRegexSanitizer> UriRegexSanitizers =>
[
.. base.UriRegexSanitizers,
new UriRegexSanitizer(new UriRegexSanitizerBody
{
Regex = "([?&](?:t|c|s|h)=)(?<value>[^&]+)",
GroupForReplace = "value",
Value = "sanitized"
}),
new UriRegexSanitizer(new UriRegexSanitizerBody
{
Regex = @"resource[Gg]roups/([^?\\/]+)",
GroupForReplace = "1",
Value = "Sanitized"
}),
// Keep older recordings compatible with the backend-required operationId query parameter.
new UriRegexSanitizer(new UriRegexSanitizerBody
{
Regex = "&operationId=[^&]+",
Value = string.Empty
})
];
public override List<BodyKeySanitizer> BodyKeySanitizers =>
[
.. base.BodyKeySanitizers,
new BodyKeySanitizer(new BodyKeySanitizerBody("$..subscription")
{
Value = "Sanitized"
}),
// Preserve ARM ID shapes; GA SDK models and command inputs validate them.
new BodyKeySanitizer(new BodyKeySanitizerBody("$..resourceGroup")
{
Value = "Sanitized"
}),
// Recovery plan creation generates a new default-group GUID on each run.
new BodyKeySanitizer(new BodyKeySanitizerBody("$.properties.recoveryGroupsSetting.defaultGroup.properties.groupUniqueId")
{
Value = Guid.Empty.ToString()
})
];
}
@@ -1,8 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// cspell:ignore slis
using System.ClientModel.Primitives;
using System.Text.Json;
using Azure.Core;
using Azure.Mcp.Tools.ResilienceManagement.Commands;
using Azure.Mcp.Tools.ResilienceManagement.Models;
using Azure.Mcp.Tools.ResilienceManagement.Services;
using Azure.ResourceManager.Models;
@@ -17,6 +20,102 @@ 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";
[Theory]
[InlineData(true)]
[InlineData(false)]
public void MapGoalAssignment_PreservesGaResiliencyRequirement(bool requireZonalResiliency)
{
BinaryData response = BinaryData.FromObjectAsJson(new
{
name = "goal",
properties = new { requireZonalResiliency, provisioningState = "Succeeded" }
});
GoalAssignmentData data = ModelReaderWriter.Read<GoalAssignmentData>(response)!;
GoalAssignmentInfo result = ResilienceManagementService.MapGoalAssignment(data, response);
Assert.NotNull(result.Properties);
Assert.Equal(requireZonalResiliency, result.Properties.RequireZonalResiliency);
Assert.Equal("Succeeded", result.Properties.ProvisioningState);
Assert.Null(result.Properties.ServiceLevelResources);
Assert.Null(result.Properties.ErrorDetails);
}
[Fact]
public void MapGoalAssignment_PreservesGaServiceLevelResourcesAndErrorDetailsAfterResponseIsDisposed()
{
const string sliId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Monitor/slis/availability";
BinaryData response = BinaryData.FromObjectAsJson(new
{
name = "goal",
properties = new
{
requireZonalResiliency = true,
serviceLevelResources = new[] { new { serviceLevelIndicatorResourceId = sliId } },
provisioningState = "Failed",
errorDetails = new { code = "GoalAssignmentFailed", message = "Assignment failed." }
}
});
GoalAssignmentData data = ModelReaderWriter.Read<GoalAssignmentData>(response)!;
GoalAssignmentInfo result = ResilienceManagementService.MapGoalAssignment(data, response);
Assert.NotNull(result.Properties);
Assert.NotNull(result.Properties.ServiceLevelResources);
JsonElement serviceLevelResource = Assert.Single(result.Properties.ServiceLevelResources.Value.EnumerateArray());
Assert.Equal(sliId, serviceLevelResource.GetProperty("serviceLevelIndicatorResourceId").GetString());
Assert.NotNull(result.Properties.ErrorDetails);
Assert.Equal("GoalAssignmentFailed", result.Properties.ErrorDetails.Value.GetProperty("code").GetString());
Assert.Equal("Failed", result.Properties.ProvisioningState);
}
[Fact]
public void MapGoalAssignment_DoesNotEmitPreviewOnlyFields()
{
BinaryData response = BinaryData.FromObjectAsJson(new
{
name = "goal",
properties = new { goalAssignmentType = "System", goalTemplateId = "/providers/Microsoft.AzureResilienceManagement/goalTemplates/default" }
});
GoalAssignmentData data = ModelReaderWriter.Read<GoalAssignmentData>(response)!;
GoalAssignmentInfo result = ResilienceManagementService.MapGoalAssignment(data, response);
string json = JsonSerializer.Serialize(result.Properties, ResilienceManagementJsonContext.Default.GoalAssignmentInfoProperties);
Assert.DoesNotContain("goalAssignmentType", json, StringComparison.Ordinal);
Assert.DoesNotContain("goalTemplateId", json, StringComparison.Ordinal);
Assert.Null(result.Properties!.RequireZonalResiliency);
}
[Fact]
public void MapGoalResource_PreservesGaZonalResiliencyAfterResponseIsDisposed()
{
BinaryData response = BinaryData.FromObjectAsJson(new
{
name = "resource",
properties = new
{
resourceArmId = UserAssignedIdentityResourceId,
provisioningState = "NeedsAttention",
zonalResiliency = new { goalParticipation = "Excluded", attestationStatus = "NotAttested", exclusionReason = "UnsupportedResource" },
disasterRecoveryAttestationStatus = "Attested"
}
});
GoalMembersData data = ModelReaderWriter.Read<GoalMembersData>(response)!;
GoalResourceInfo result = ResilienceManagementService.MapGoalResource(data, response);
Assert.NotNull(result.Properties);
Assert.Equal("NeedsAttention", result.Properties.ProvisioningState);
Assert.Equal(UserAssignedIdentityResourceId, result.Properties.ResourceArmId);
Assert.NotNull(result.Properties.ZonalResiliency);
Assert.Equal("Excluded", result.Properties.ZonalResiliency.Value.GetProperty("goalParticipation").GetString());
Assert.Equal("UnsupportedResource", result.Properties.ZonalResiliency.Value.GetProperty("exclusionReason").GetString());
string json = JsonSerializer.Serialize(result.Properties, ResilienceManagementJsonContext.Default.GoalResourceInfoProperties);
Assert.DoesNotContain("disasterRecoveryAttestationStatus", json, StringComparison.Ordinal);
Assert.DoesNotContain("serviceGroupMemberships", json, StringComparison.Ordinal);
}
[Theory]
[InlineData("Recovery job retry")]
[InlineData("Recovery job resume")]
@@ -143,7 +242,7 @@ public sealed class ResilienceManagementServiceTests
[Fact]
public void CreateReadinessError_UsesEmptyRecommendationsWhenProviderOmitsThem()
{
JobErrorInfo error = ModelReaderWriter.Read<JobErrorInfo>(BinaryData.FromObjectAsJson(new
ResilienceManagementJobErrorInfo error = ModelReaderWriter.Read<ResilienceManagementJobErrorInfo>(BinaryData.FromObjectAsJson(new
{
errorCode = "NotReady",
errorMessage = "Resource requires attention."
@@ -285,6 +384,29 @@ public sealed class ResilienceManagementServiceTests
Assert.Equal(expected, ResilienceManagementService.IsTerminalJobStatus(status));
}
[Fact]
public void CreateDrillRunReprotectContent_WithoutSelection_SendsEmptyReprotectProperties()
{
DrillRunReprotectContent content = ResilienceManagementService.CreateDrillRunReprotectContent(null);
using JsonDocument document = JsonDocument.Parse(ModelReaderWriter.Write(content, new ModelReaderWriterOptions("W")));
JsonElement reprotectProperties = document.RootElement.GetProperty("reprotectProperties");
Assert.Equal(JsonValueKind.Object, reprotectProperties.ValueKind);
Assert.Empty(reprotectProperties.EnumerateObject());
}
[Fact]
public void CreateDrillRunReprotectContent_WithSelection_SendsSelectedResourceIds()
{
const string recoveryResourceId = "/providers/Microsoft.Management/serviceGroups/sg/providers/Microsoft.AzureResilienceManagement/recoveryPlans/plan/recoveryResources/11111111-1111-1111-1111-111111111111";
DrillRunReprotectContent content = ResilienceManagementService.CreateDrillRunReprotectContent([recoveryResourceId]);
Assert.Equal(recoveryResourceId, Assert.Single(content.ReprotectRequestSelectedResourceIds).ToString());
string json = ModelReaderWriter.Write(content, new ModelReaderWriterOptions("W")).ToString();
Assert.Contains(recoveryResourceId, json, StringComparison.Ordinal);
}
[Fact]
public void CreateRecoveryGroupsSetting_ForNewPlan_GeneratesDefaultGroupId()
{
@@ -492,7 +614,7 @@ public sealed class ResilienceManagementServiceTests
[Fact]
public void CreateReadinessError_DefaultsMissingRecommendationsToEmptyList()
{
JobErrorInfo providerError = ModelReaderWriter.Read<JobErrorInfo>(BinaryData.FromObjectAsJson(new
ResilienceManagementJobErrorInfo providerError = ModelReaderWriter.Read<ResilienceManagementJobErrorInfo>(BinaryData.FromObjectAsJson(new
{
errorCode = "NotReady",
errorMessage = "The recoveryplan is not ready."
@@ -514,7 +636,7 @@ public sealed class ResilienceManagementServiceTests
[resourceId],
"Allowed");
Assert.Equal(FailoverDirectionTypes.FromSpecificLocations, result.FailoverDirection);
Assert.Equal(ResilienceManagementFailoverDirection.FromSpecificLocations, result.FailoverDirection);
Assert.NotNull(result.FailoverRequestProperties);
Assert.Equal(["eastus", "westus2-az3"], result.FailoverRequestProperties.SourceLocations);
Assert.Equal(resourceId, Assert.Single(result.FailoverRequestProperties.SelectedResourceIds).ToString());
@@ -15,7 +15,7 @@ namespace Azure.Mcp.Tools.ResilienceManagement.Tests.UsagePlans;
public sealed class UsagePlanCreateCommandTests : SubscriptionCommandUnitTestsBase<UsagePlanCreateCommand, IResilienceManagementService>
{
private const string ValidArgs = "--resource-group rg --usage-plan up1 --plan-type Basic --subscription sub";
private const string ValidArgs = "--resource-group rg --usage-plan up1 --plan-type Standard --subscription sub";
[Fact]
public void Constructor_InitializesCommandCorrectly()
@@ -28,9 +28,9 @@ public sealed class UsagePlanCreateCommandTests : SubscriptionCommandUnitTestsBa
[Theory]
[InlineData(ValidArgs, true)]
[InlineData("--usage-plan up1 --plan-type Basic --subscription sub", false)] // Missing resource group
[InlineData("--resource-group rg --plan-type Basic --subscription sub", false)] // Missing usage plan
[InlineData("--resource-group rg --usage-plan up1 --plan-type Basic", false)] // Missing subscription
[InlineData("--usage-plan up1 --plan-type Standard --subscription sub", false)] // Missing resource group
[InlineData("--resource-group rg --plan-type Standard --subscription sub", false)] // Missing usage plan
[InlineData("--resource-group rg --usage-plan up1 --plan-type Standard", false)] // Missing subscription
[InlineData("", false)] // No parameters
public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed)
{
@@ -58,6 +58,26 @@ public sealed class UsagePlanCreateCommandTests : SubscriptionCommandUnitTestsBa
}
}
[Fact]
public async Task ExecuteAsync_RejectsRemovedBasicPlanType()
{
var response = await ExecuteCommandAsync(
"--resource-group", "rg",
"--usage-plan", "up1",
"--plan-type", "Basic",
"--subscription", "sub");
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
Assert.Contains("Standard", response.Message, StringComparison.OrdinalIgnoreCase);
await Service.DidNotReceive().CreateUsagePlanAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UsagePlanKind>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>());
}
[Theory]
[InlineData("bad_name")]
[InlineData("1234567890123456789012345")]
@@ -67,7 +87,7 @@ public sealed class UsagePlanCreateCommandTests : SubscriptionCommandUnitTestsBa
var response = await ExecuteCommandAsync(
"--resource-group", "rg",
"--usage-plan", usagePlan,
"--plan-type", "Basic",
"--plan-type", "Standard",
"--subscription", "sub");
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
@@ -97,7 +117,7 @@ public sealed class UsagePlanCreateCommandTests : SubscriptionCommandUnitTestsBa
"up1",
"Microsoft.AzureResilienceManagement/usagePlans",
"global",
Properties: new("Basic", "Succeeded")));
Properties: new("Standard", "Succeeded")));
// Act
var response = await ExecuteCommandAsync(ValidArgs);
@@ -2,5 +2,5 @@
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "",
"TagPrefix": "Azure.Mcp.Tools.ResilienceManagement.Tests",
"Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_d1a71fffa6"
"Tag": "Azure.Mcp.Tools.ResilienceManagement.Tests_f9a321cb91"
}
@@ -17,7 +17,7 @@ $subscriptionId = $testSettings.SubscriptionId
$tenantId = $testSettings.TenantId
$serviceGroupApiVersion = '2024-02-01-preview'
$membershipApiVersion = '2023-09-01-preview'
$resilienceApiVersion = '2026-04-01-preview'
$resilienceApiVersion = '2026-10-01'
$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)) {
@@ -47,6 +47,13 @@ function Get-OutputValue {
return $outputs.PSObject.Properties[$Name.ToUpperInvariant()].Value
}
function Get-OptionalOutputValue {
param([string] $Name)
$property = $outputs.PSObject.Properties[$Name.ToUpperInvariant()]
return $property ? $property.Value : $null
}
function Wait-ResourceDeleted {
param([string] $ResourceId, [string] $ApiVersion)
@@ -68,14 +75,19 @@ function Wait-ResourceDeleted {
}
function Remove-Resource {
[CmdletBinding(SupportsShouldProcess)]
param([string] $ResourceId, [string] $ApiVersion)
if ([string]::IsNullOrWhiteSpace($ResourceId)) {
return
}
if (!$PSCmdlet.ShouldProcess($ResourceId, 'Delete resource')) {
return
}
$path = "${ResourceId}?api-version=$ApiVersion"
Write-Host "DELETE $path"
Write-Information "DELETE $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)"
@@ -86,9 +98,14 @@ function Remove-Resource {
}
}
function Remove-RecoveryPlans {
function Remove-RecoveryPlanCollection {
[CmdletBinding(SupportsShouldProcess)]
param([string] $ServiceGroupId)
if (!$PSCmdlet.ShouldProcess($ServiceGroupId, 'Delete recovery plans')) {
return
}
$collectionPath = "$ServiceGroupId/providers/Microsoft.AzureResilienceManagement/recoveryPlans?api-version=$resilienceApiVersion"
$response = Invoke-ManagementRestMethod -Method GET -Path $collectionPath
if ($response.StatusCode -eq 404) {
@@ -101,7 +118,148 @@ function Remove-RecoveryPlans {
$content = $response.Content | ConvertFrom-Json
foreach ($recoveryPlan in $content.value) {
Remove-Resource -ResourceId $recoveryPlan.id -ApiVersion $resilienceApiVersion
Remove-Resource -ResourceId $recoveryPlan.id -ApiVersion $resilienceApiVersion -Confirm:$false
}
}
function Remove-ResourceCollection {
[CmdletBinding(SupportsShouldProcess)]
param(
[string] $CollectionId,
[string] $ApiVersion
)
if (!$PSCmdlet.ShouldProcess($CollectionId, 'Delete resources in collection')) {
return
}
$collectionPath = "${CollectionId}?api-version=$ApiVersion"
$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 ($resource in $content.value) {
Remove-Resource -ResourceId $resource.id -ApiVersion $ApiVersion -Confirm:$false
}
}
function Remove-PrincipalRoleAssignment {
[CmdletBinding(SupportsShouldProcess)]
param(
[string] $Scope,
[string] $PrincipalId
)
if ([string]::IsNullOrWhiteSpace($PrincipalId)) {
return
}
if (!$PSCmdlet.ShouldProcess($Scope, "Delete role assignments for principal $PrincipalId")) {
return
}
$apiVersion = '2022-04-01'
$collectionPath = "$Scope/providers/Microsoft.Authorization/roleAssignments?api-version=$apiVersion&`$filter=atScope()"
$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 ($assignment in $content.value) {
if ($assignment.properties.principalId -eq $PrincipalId) {
Remove-Resource -ResourceId $assignment.id -ApiVersion $apiVersion -Confirm:$false
}
}
}
function Remove-DrillsFixture {
[CmdletBinding(SupportsShouldProcess)]
param()
$drillsResourceGroupName = Get-OptionalOutputValue -Name 'drillsResourceGroupName'
if ([string]::IsNullOrWhiteSpace($drillsResourceGroupName) -or
!$PSCmdlet.ShouldProcess($drillsResourceGroupName, 'Delete drills fixture resources')) {
return
}
$serviceGroupName = Get-OutputValue -Name 'drillsServiceGroupName'
$lifecycleServiceGroupName = Get-OutputValue -Name 'drillsLifecycleServiceGroupName'
$usagePlanId = Get-OutputValue -Name 'drillsUsagePlanId'
$drillName = Get-OutputValue -Name 'drillsDrillName'
$deleteAssetResourceGroupName = Get-OutputValue -Name 'drillsDeleteAssetResourceGroupName'
$requiredOutputs = @{
drillsServiceGroupName = $serviceGroupName
drillsLifecycleServiceGroupName = $lifecycleServiceGroupName
drillsUsagePlanId = $usagePlanId
drillsDrillName = $drillName
drillsDeleteAssetResourceGroupName = $deleteAssetResourceGroupName
}
foreach ($requiredOutput in $requiredOutputs.GetEnumerator()) {
if ([string]::IsNullOrWhiteSpace($requiredOutput.Value)) {
throw "Deployment output '$($requiredOutput.Key)' is required for safe drills fixture teardown."
}
}
$serviceGroupId = "/providers/Microsoft.Management/serviceGroups/$serviceGroupName"
$lifecycleServiceGroupId = "/providers/Microsoft.Management/serviceGroups/$lifecycleServiceGroupName"
$resilienceBase = "$serviceGroupId/providers/Microsoft.AzureResilienceManagement"
$lifecycleResilienceBase = "$lifecycleServiceGroupId/providers/Microsoft.AzureResilienceManagement"
$drillsResourceGroupId = "/subscriptions/$subscriptionId/resourceGroups/$drillsResourceGroupName"
$deleteAssetResourceGroupId = "/subscriptions/$subscriptionId/resourceGroups/$deleteAssetResourceGroupName"
$drillId = "$resilienceBase/drills/$drillName"
$drillResponse = Invoke-ManagementRestMethod -Method GET -Path "$drillId`?api-version=$resilienceApiVersion"
if ($drillResponse.StatusCode -eq 200) {
$drillPrincipalId = ($drillResponse.Content | ConvertFrom-Json).identity.principalId
Remove-PrincipalRoleAssignment -Scope $serviceGroupId -PrincipalId $drillPrincipalId -Confirm:$false
Remove-PrincipalRoleAssignment -Scope "/subscriptions/$subscriptionId" -PrincipalId $drillPrincipalId -Confirm:$false
}
elseif ($drillResponse.StatusCode -ne 404) {
throw "GET $drillId failed with status $($drillResponse.StatusCode): $($drillResponse.Content)"
}
Remove-ResourceCollection -CollectionId "$resilienceBase/drills" -ApiVersion $resilienceApiVersion -Confirm:$false
Remove-ResourceCollection -CollectionId "$lifecycleResilienceBase/drills" -ApiVersion $resilienceApiVersion -Confirm:$false
Remove-RecoveryPlanCollection -ServiceGroupId $serviceGroupId -Confirm:$false
Remove-RecoveryPlanCollection -ServiceGroupId $lifecycleServiceGroupId -Confirm:$false
Remove-Resource -ResourceId "$usagePlanId/enrollments/$serviceGroupName" -ApiVersion $resilienceApiVersion
Remove-Resource -ResourceId "$usagePlanId/enrollments/$lifecycleServiceGroupName" -ApiVersion $resilienceApiVersion
Remove-Resource -ResourceId "$drillsResourceGroupId/providers/Microsoft.Relationships/serviceGroupMember/drills-ga" -ApiVersion $membershipApiVersion
Remove-Resource -ResourceId "$deleteAssetResourceGroupId/providers/Microsoft.Relationships/serviceGroupMember/delete-fixture" -ApiVersion $membershipApiVersion
Remove-Resource -ResourceId $lifecycleServiceGroupId -ApiVersion $serviceGroupApiVersion
Remove-Resource -ResourceId $serviceGroupId -ApiVersion $serviceGroupApiVersion
}
# Drills teardown can be blocked by service-side drill state; still remove the general fixture and
# start deleting the dedicated drills resource groups so billable resources do not leak.
try {
Remove-DrillsFixture -Confirm:$false
}
catch {
Write-Warning "Drills fixture teardown did not complete: $($_.Exception.Message)"
}
$drillsResourceGroupName = Get-OptionalOutputValue -Name 'drillsResourceGroupName'
$drillsDeleteAssetResourceGroupName = Get-OptionalOutputValue -Name 'drillsDeleteAssetResourceGroupName'
foreach ($groupName in @($drillsDeleteAssetResourceGroupName, $drillsResourceGroupName)) {
if ([string]::IsNullOrWhiteSpace($groupName)) {
continue
}
$path = "/subscriptions/$subscriptionId/resourceGroups/$groupName`?api-version=2024-03-01"
Write-Information "DELETE $path"
$response = Invoke-ManagementRestMethod -Method DELETE -Path $path
if ($response.StatusCode -notin @(200, 202, 204, 404)) {
Write-Warning "DELETE $path failed with status $($response.StatusCode): $($response.Content)"
}
}
@@ -115,8 +273,6 @@ $lifecycleEnrollmentName = Get-OutputValue -Name 'lifecycleEnrollmentName'
$planLifecycleEnrollmentName = Get-OutputValue -Name 'planLifecycleEnrollmentName'
$workflowEnrollmentName = Get-OutputValue -Name 'workflowEnrollmentName'
$goalAssignmentName = Get-OutputValue -Name 'goalAssignmentName'
$goalTemplateName = Get-OutputValue -Name 'goalTemplateName'
$drillName = Get-OutputValue -Name 'drillName'
$requiredOutputs = @{
serviceGroupName = $serviceGroupName
@@ -125,8 +281,6 @@ $requiredOutputs = @{
enrollmentName = $enrollmentName
lifecycleEnrollmentName = $lifecycleEnrollmentName
goalAssignmentName = $goalAssignmentName
goalTemplateName = $goalTemplateName
drillName = $drillName
}
foreach ($requiredOutput in $requiredOutputs.GetEnumerator()) {
@@ -142,17 +296,18 @@ $workflowServiceGroupId = "/providers/Microsoft.Management/serviceGroups/$workfl
$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
# Deployments created before drills moved to the dedicated fixture may still hold general drills.
Remove-ResourceCollection -CollectionId "$resilienceBase/drills" -ApiVersion $resilienceApiVersion -Confirm:$false
Remove-ResourceCollection -CollectionId "$lifecycleServiceGroupId/providers/Microsoft.AzureResilienceManagement/drills" -ApiVersion $resilienceApiVersion -Confirm:$false
Remove-RecoveryPlanCollection -ServiceGroupId $serviceGroupId -Confirm:$false
Remove-RecoveryPlanCollection -ServiceGroupId $lifecycleServiceGroupId -Confirm:$false
if (![string]::IsNullOrWhiteSpace($planLifecycleServiceGroupName)) {
Remove-RecoveryPlans -ServiceGroupId $planLifecycleServiceGroupId
Remove-RecoveryPlanCollection -ServiceGroupId $planLifecycleServiceGroupId -Confirm:$false
}
if (![string]::IsNullOrWhiteSpace($workflowServiceGroupName)) {
Remove-RecoveryPlans -ServiceGroupId $workflowServiceGroupId
Remove-RecoveryPlanCollection -ServiceGroupId $workflowServiceGroupId -Confirm:$false
}
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
if (![string]::IsNullOrWhiteSpace($planLifecycleEnrollmentName)) {
@@ -0,0 +1,605 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# cspell:ignore authenticationsettings discoveryrules relationshipresources resourcecontainers servicegroupmember Fmanagement RHDS RHRO RHUB DRILLSAUTOMATIONACCOUNTID DRILLSDELETEASSETRESOURCEGROUPNAME DRILLSDRILLNAME DRILLSDRILLRESOURCEID DRILLSDRILLRUNNAME DRILLSDRILLRUNRESOURCENAME DRILLSHEALTHMODELID DRILLSLIFECYCLESERVICEGROUPNAME DRILLSRECOVERYPLANNAME DRILLSRECOVERYRESOURCEID DRILLSRESOURCEGROUPNAME DRILLSSERVICEGROUPNAME DRILLSSOURCELOCATION DRILLSUSAGEPLANID DRILLSVMID
# Invoked by test-resources-post.ps1. Configures the VM-backed drills fixture that test-resources.bicep
# deploys into its dedicated resource group, then adds DRILLS* entries to $DeploymentOutputs so the
# single New-TestSettings call in test-resources-post.ps1 writes them with the general outputs.
param(
[Parameter(Mandatory)]
[hashtable] $DeploymentOutputs,
[Parameter(Mandatory)]
[string] $SubscriptionId,
[Parameter(Mandatory)]
[string] $TenantId
)
$ErrorActionPreference = 'Stop'
$apiVersion = '2026-10-01'
$fixturePurpose = 'mcp-drills-ga-live-tests'
$resourceGroupName = $DeploymentOutputs['DRILLSRESOURCEGROUPNAME']
$deleteAssetResourceGroupName = $DeploymentOutputs['DRILLSDELETEASSETRESOURCEGROUPNAME']
$usagePlanId = $DeploymentOutputs['DRILLSUSAGEPLANID']
$serviceGroupName = $DeploymentOutputs['DRILLSSERVICEGROUPNAME']
$lifecycleServiceGroupName = $DeploymentOutputs['DRILLSLIFECYCLESERVICEGROUPNAME']
$recoveryPlanName = $DeploymentOutputs['DRILLSRECOVERYPLANNAME']
$drillName = $DeploymentOutputs['DRILLSDRILLNAME']
$vmId = $DeploymentOutputs['DRILLSVMID']
$automationId = $DeploymentOutputs['DRILLSAUTOMATIONACCOUNTID']
$healthModelId = $DeploymentOutputs['DRILLSHEALTHMODELID']
$location = $DeploymentOutputs['LOCATION']
foreach ($name in @($resourceGroupName, $deleteAssetResourceGroupName)) {
$group = Get-AzResourceGroup -Name $name
if ($group.Tags['Purpose'] -ne $fixturePurpose) {
throw "Refusing to configure '$name'; it is not a dedicated drills fixture resource group."
}
}
$groupId = "/subscriptions/$SubscriptionId/resourceGroups/$resourceGroupName"
$deleteGroupId = "/subscriptions/$SubscriptionId/resourceGroups/$deleteAssetResourceGroupName"
if (!$vmId.StartsWith("$groupId/providers/Microsoft.Compute/virtualMachines/", [StringComparison]::OrdinalIgnoreCase)) {
throw 'The target VM must belong to the dedicated drills resource group.'
}
function Invoke-FixtureArm {
param(
[string] $Path,
[string] $Method = 'GET',
[object] $Body,
[switch] $AllowNotFound,
[switch] $Action,
[switch] $WaitForLinkedAuthorization,
[string] $ContentType = 'application/json'
)
$uri = if ($Path.StartsWith('/')) { "https://management.azure.com$Path" } else { $Path }
if (([Uri]$uri).Host -ne 'management.azure.com' -or ([Uri]$uri).Scheme -ne 'https') {
throw 'Fixture requests and operation polling must use public Azure ARM.'
}
$safePath = ([Uri]$uri).AbsolutePath
if ($Method -ne 'GET') { Write-Host "$Method $safePath" }
$headers = @{}
if ($Action) {
$operationId = [Guid]::NewGuid().ToString()
$headers['operation-id'] = $operationId
$uri += "&operationId=$operationId"
}
$parameters = @{
Uri = $uri
Method = $Method
Authentication = 'Bearer'
Token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com/').Token
Headers = $headers
ContentType = $ContentType
SkipHttpErrorCheck = $true
}
if ($null -ne $Body) {
$parameters.Body = if ($Body -is [string]) { $Body } else { ConvertTo-Json $Body -Depth 30 -Compress }
}
$deadline = [DateTime]::UtcNow.AddMinutes(30)
$transientAttempts = 0
do {
try {
$response = Invoke-WebRequest @parameters
}
catch [System.Net.Http.HttpRequestException] {
throw [InvalidOperationException]::new("$Method $safePath failed before receiving a complete ARM response; inspect resource state before retrying.", $_.Exception)
}
if ($Method -in @('GET', 'PUT') -and $response.StatusCode -in @(429, 500, 502, 503, 504) -and $transientAttempts -lt 3) {
$transientAttempts++
Write-Warning "$Method $safePath returned HTTP $($response.StatusCode); retrying the idempotent request."
Start-Sleep -Seconds (15 * $transientAttempts)
continue
}
if ($response.StatusCode -ne 403 -or [DateTime]::UtcNow -ge $deadline) { break }
$errorCode = try { ($response.Content | ConvertFrom-Json).error.code } catch { $null }
# A new service group grants its creator access asynchronously, and enrollments need the
# service-group link to propagate. A rejected request was not executed, so it is safe to retry.
$isNewServiceGroupAccess = $errorCode -eq 'AuthorizationFailed' -and $safePath.StartsWith('/providers/Microsoft.Management/serviceGroups/', [StringComparison]::OrdinalIgnoreCase)
$isLinkedAuthorization = $WaitForLinkedAuthorization -and $errorCode -eq 'LinkedAuthorizationFailed'
if (!$isNewServiceGroupAccess -and !$isLinkedAuthorization) { break }
Write-Host "Waiting for service-group authorization to propagate for $safePath."
Start-Sleep -Seconds 20
$parameters.Token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com/').Token
} while ($true)
if ($response.StatusCode -eq 404 -and $AllowNotFound) {
return $null
}
if ($response.StatusCode -notin @(200, 201, 202, 204)) {
throw "$Method $safePath failed ($($response.StatusCode)): $($response.Content)"
}
return $response
}
function Get-FixtureResource {
param([string] $Path, [switch] $AllowNotFound)
$response = Invoke-FixtureArm -Path $Path -AllowNotFound:$AllowNotFound
if ($null -ne $response -and $response.Content) {
return $response.Content | ConvertFrom-Json -AsHashtable
}
}
function Wait-FixtureResource {
param([string] $Path)
$deadline = [DateTime]::UtcNow.AddMinutes(15)
do {
$resource = Get-FixtureResource -Path $Path -AllowNotFound
$state = $resource.properties.provisioningState
if ($state -eq 'Succeeded') { return $resource }
if ($state -in @('Failed', 'Canceled')) {
throw "Fixture provisioning $state at ${Path}: $(ConvertTo-Json $resource.properties.errorDetails -Depth 15 -Compress)"
}
Start-Sleep -Seconds 10
} while ([DateTime]::UtcNow -lt $deadline)
throw "Timed out provisioning $Path. Last state: $state."
}
function New-FixtureResource {
[CmdletBinding(SupportsShouldProcess)]
param([string] $Path, [hashtable] $Body, [switch] $WaitForLinkedAuthorization)
$existing = Get-FixtureResource -Path $Path -AllowNotFound
if (!$existing) {
if (!$PSCmdlet.ShouldProcess($Path, 'Create fixture resource')) { return $null }
$response = Invoke-FixtureArm -Path $Path -Method PUT -Body $Body -WaitForLinkedAuthorization:$WaitForLinkedAuthorization
Wait-FixtureOperation -Response $response -Path $Path
}
return Wait-FixtureResource -Path $Path
}
function Add-FixtureRole {
param([string] $Scope, [string] $PrincipalId, [string] $RoleId)
if (!$PrincipalId) { throw "A managed identity is required for the role at $Scope." }
$path = "$Scope/providers/Microsoft.Authorization/roleAssignments"
$assignments = Get-FixtureResource -Path "$path`?api-version=2022-04-01&`$filter=atScope()"
$existing = $assignments.value | Where-Object {
$_.properties.principalId -eq $PrincipalId -and $_.properties.roleDefinitionId.EndsWith("/$RoleId")
}
if (!$existing) {
Invoke-FixtureArm -Method PUT -Path "$path/$([Guid]::NewGuid())?api-version=2022-04-01" -Body @{
properties = @{
principalId = $PrincipalId
principalType = 'ServicePrincipal'
roleDefinitionId = "/providers/Microsoft.Authorization/roleDefinitions/$RoleId"
}
} | Out-Null
}
}
function Wait-FixtureOperation {
param([object] $Response, [string] $Path)
$pollUri = $Response.Headers['Azure-AsyncOperation'] | Select-Object -First 1
if (!$pollUri) { $pollUri = $Response.Headers['Location'] | Select-Object -First 1 }
if (!$pollUri) {
if ($Response.StatusCode -eq 202) { throw "Accepted operation has no polling URL: $Path." }
return
}
$deadline = [DateTime]::UtcNow.AddMinutes(15)
do {
Start-Sleep -Seconds 10
$poll = Invoke-FixtureArm -Path $pollUri
$payload = if ($poll.Content) { $poll.Content | ConvertFrom-Json } else { $null }
$status = $payload.status ?? $payload.properties.provisioningState
if ($status -in @('Failed', 'Canceled')) { throw "Fixture operation failed: $($poll.Content)" }
if ($status -eq 'Succeeded' -or ($poll.StatusCode -in @(200, 204) -and !$status)) { return }
} while ([DateTime]::UtcNow -lt $deadline)
throw "Timed out waiting for $Path. Last operation status: $status."
}
function Invoke-FixtureAction {
param([string] $Path, [hashtable] $Body, [string[]] $RetryOnErrorCodes = @(), [int] $MaxAttempts = 1)
for ($attempt = 1; ; $attempt++) {
try {
$response = Invoke-FixtureArm -Method POST -Action -Path $Path -Body $Body
Wait-FixtureOperation -Response $response -Path $Path
return
}
catch {
$message = $_.Exception.Message
$isRetryable = @($RetryOnErrorCodes | Where-Object { $message.Contains($_) }).Count -gt 0
if ($attempt -ge $MaxAttempts -or !$isRetryable) { throw }
Write-Warning "Retrying $Path after a transient service condition (attempt $attempt of $MaxAttempts)."
Start-Sleep -Seconds 120
}
}
}
# Drill-run stage operations can remain in progress for the whole stage, so callers poll the run's
# supported verbs instead of the operation status.
function Invoke-DrillRunVerb {
param([string] $Path, [hashtable] $Body)
for ($attempt = 1; ; $attempt++) {
try {
Invoke-FixtureArm -Method POST -Action -Path $Path -Body $Body | Out-Null
return
}
catch {
# The previous stage operation releases the run lock asynchronously.
if ($attempt -ge 40 -or !$_.Exception.Message.Contains('(409)')) { throw }
Start-Sleep -Seconds 15
}
}
}
function Assert-NoFailedDrillTask {
param([object] $Task)
if ($Task.status -in @('Failed', 'Cancelled')) {
throw "Drill run task '$($Task.taskName)' $($Task.status): $(ConvertTo-Json $Task.errorDetails -Depth 15 -Compress)"
}
foreach ($child in @($Task.jobExtendedInfo.tasksList) + @($Task.subTasksList)) {
if ($null -ne $child) { Assert-NoFailedDrillTask -Task $child }
}
}
function Wait-DrillRunVerb {
param([string] $RunId, [string] $Stage, [string[]] $Verbs)
Write-Host "Waiting for drill run stage $Stage to offer $($Verbs -join ' or ')."
$deadline = [DateTime]::UtcNow.AddMinutes(30)
do {
$run = Get-FixtureResource -Path "$RunId`?api-version=$apiVersion"
Assert-NoFailedDrillTask -Task $run.properties
$stageVerbs = @($run.properties.supportedVerbsForStage | Where-Object { $_.drillRunStage -eq $Stage } | ForEach-Object { $_.supportedVerbs })
if (@($stageVerbs | Where-Object { $_ -in $Verbs }).Count -gt 0) { return }
Start-Sleep -Seconds 15
} while ([DateTime]::UtcNow -lt $deadline)
throw "Drill run stage $Stage did not offer $($Verbs -join ' or ') within the timeout."
}
# Recovery Orchestration rejects failover unless the plan is Ready, and discovering new service-group
# members (for example, resources created by the fixture or the drill) moves it to Warning. Explicitly
# exclude every member except the target VM, then finalize and re-run readiness until the plan is Ready.
function Wait-FixturePlanReady {
for ($attempt = 1; ; $attempt++) {
$members = @((Get-FixtureResource -Path "$planId/recoveryResources?api-version=$apiVersion").value |
Where-Object { $_.properties.resourceId -ne $vmId -and $_.properties.inclusionState -ne 'Included' })
if ($members.Count -gt 0) {
Invoke-FixtureAction -Path "$planId/updateResources?api-version=$apiVersion" -Body @{
resourcesToUpdate = @($members | ForEach-Object {
@{ id = $_.id; properties = @{ recoveryResourceUniqueId = $_.name; inclusionState = 'Excluded' } }
})
resourcesToRemove = @()
}
Wait-FixtureResource -Path "$planId`?api-version=$apiVersion" | Out-Null
Invoke-FixtureAction -Path "$planId/finalize?api-version=$apiVersion" -RetryOnErrorCodes @('RHUBUserErrorAzureResourceAddedToServiceGroup') -MaxAttempts 3
}
Invoke-FixtureAction -Path "$planId/checkReadiness?api-version=$apiVersion" -RetryOnErrorCodes @('RHUBUserErrorAzureResourceAddedToServiceGroup') -MaxAttempts 3
$planState = (Get-FixtureResource -Path "$planId`?api-version=$apiVersion").properties.planState
if ($planState -eq 'Ready') { return }
if ($attempt -ge 4) { throw "Recovery plan $recoveryPlanName did not return to Ready (state: $planState)." }
Write-Warning "Recovery plan state is $planState; excluding newly discovered members and retrying."
Start-Sleep -Seconds 60
}
}
function Wait-FixtureServiceGroupIndex {
param([string] $Id)
$deadline = [DateTime]::UtcNow.AddMinutes(15)
do {
$response = Invoke-FixtureArm -Method POST -Path '/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01' -Body @{
query = "resourcecontainers | where id =~ '$Id' | project id"
}
if (($response.Content | ConvertFrom-Json).count -eq 1) { return }
Start-Sleep -Seconds 15
} while ([DateTime]::UtcNow -lt $deadline)
throw "Service group $Id exists in ARM but is not indexed in tenant Resource Graph. Dependent provisioning cannot proceed."
}
# 1) Isolated service groups: the primary group owns the VM drill; the lifecycle group owns
# a disk-only member so creation tests can create and delete their own drills.
$serviceGroupId = "/providers/Microsoft.Management/serviceGroups/$serviceGroupName"
$lifecycleId = "/providers/Microsoft.Management/serviceGroups/$lifecycleServiceGroupName"
foreach ($id in @($serviceGroupId, $lifecycleId)) {
Write-Host "Preparing isolated drills service group $id."
$name = $id.Split('/')[-1]
New-FixtureResource -Path "$id`?api-version=2024-02-01-preview" -Body @{
properties = @{ displayName = $name; parent = @{ resourceId = "/providers/Microsoft.Management/serviceGroups/$TenantId" } }
} | Out-Null
Wait-FixtureServiceGroupIndex -Id $id
}
New-FixtureResource -WaitForLinkedAuthorization -Path "$usagePlanId/enrollments/$lifecycleServiceGroupName`?api-version=$apiVersion" -Body @{
properties = @{ serviceGroupId = $lifecycleId }
} | Out-Null
# Drill creation requires a nonempty group; this disk cannot affect the fault-injection VM.
New-FixtureResource -Path "$deleteGroupId/providers/Microsoft.Compute/disks/delete-fixture-disk?api-version=2024-03-02" -Body @{
location = $location
zones = @('1')
tags = @{ purpose = $fixturePurpose }
sku = @{ name = 'Standard_LRS' }
properties = @{ creationData = @{ createOption = 'Empty' }; diskSizeGB = 4 }
} | Out-Null
Invoke-FixtureArm -Method PUT -WaitForLinkedAuthorization -Path "$deleteGroupId/providers/Microsoft.Relationships/serviceGroupMember/delete-fixture?api-version=2023-09-01-preview" -Body @{
properties = @{ targetId = $lifecycleId }
} | Out-Null
New-FixtureResource -WaitForLinkedAuthorization -Path "$usagePlanId/enrollments/$serviceGroupName`?api-version=$apiVersion" -Body @{
properties = @{ serviceGroupId = $serviceGroupId }
} | Out-Null
Invoke-FixtureArm -Method PUT -Path "$groupId/providers/Microsoft.Relationships/serviceGroupMember/drills-ga?api-version=2023-09-01-preview" -Body @{
properties = @{ targetId = $serviceGroupId }
} | Out-Null
$base = "$serviceGroupId/providers/Microsoft.AzureResilienceManagement"
$drillId = "$base/drills/$drillName"
$existingDrill = Get-FixtureResource -Path "$drillId`?api-version=$apiVersion" -AllowNotFound
if ($existingDrill -and $existingDrill.properties.executionState -ne 'NotRunning') {
throw 'An existing drill is running. Resolve its lifecycle before reconfiguring this fixture.'
}
# 2) Recovery plan that protects only the dedicated VM through custom runbooks.
$planId = "$base/recoveryPlans/$recoveryPlanName"
$plan = New-FixtureResource -Path "$planId`?api-version=$apiVersion" -Body @{
identity = @{ type = 'SystemAssigned' }
properties = @{
planType = 'Zonal'
planDescription = 'Isolated MCP Drills GA lifecycle fixture'
recoveryGroupsSetting = @{
defaultGroup = @{
properties = @{
description = 'Dedicated test VM only'
groupUniqueId = [Guid]::NewGuid().ToString()
orderId = 0
preActions = @()
postActions = @()
}
}
additionalGroups = @()
}
}
}
Add-FixtureRole -Scope $groupId -PrincipalId $plan.identity.principalId -RoleId 'acdd72a7-3385-48ef-bd42-f606fba81ae7'
Add-FixtureRole -Scope $automationId -PrincipalId $plan.identity.principalId -RoleId '4fe576fe-1146-4730-92eb-48519fa6bf9f'
# These custom recovery actions exercise orchestration, not cross-zone disaster recovery.
# They start the faulted test VM and leave an observable phase marker for assertions.
$runbookTemplate = @'
$ErrorActionPreference = 'Stop'
$vmId = '__VM_ID__'
$phase = '__PHASE__'
if (!$env:IDENTITY_ENDPOINT -or !$env:IDENTITY_HEADER) {
throw 'The fixture runbook requires the Automation account managed identity.'
}
$tokenUri = "$($env:IDENTITY_ENDPOINT)?resource=https%3A%2F%2Fmanagement.azure.com%2F&api-version=2019-08-01"
$token = Invoke-RestMethod -Uri $tokenUri -Headers @{ 'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER; Metadata = 'True' }
$headers = @{ Authorization = "Bearer $($token.access_token)" }
$base = "https://management.azure.com$vmId"
$vm = Invoke-RestMethod -Uri "$base`?api-version=2024-07-01" -Headers $headers
if ($vm.tags.purpose -ne 'mcp-drills-ga-live-tests') {
throw 'Refusing to operate on a VM without the dedicated fixture tag.'
}
Invoke-RestMethod -Method POST -Uri "$base/start?api-version=2024-07-01" -Headers $headers | Out-Null
$deadline = [DateTime]::UtcNow.AddMinutes(10)
do {
$view = Invoke-RestMethod -Uri "$base/instanceView?api-version=2024-07-01" -Headers $headers
if ($view.statuses.code -contains 'PowerState/running') { break }
Start-Sleep -Seconds 10
} while ([DateTime]::UtcNow -lt $deadline)
if ($view.statuses.code -notcontains 'PowerState/running') { throw 'The dedicated test VM did not restart.' }
$vm.tags | Add-Member -NotePropertyName 'mcp-drill-phase' -NotePropertyValue $phase -Force
$body = @{ tags = $vm.tags } | ConvertTo-Json -Depth 10
Invoke-RestMethod -Method PATCH -Uri "$base`?api-version=2024-07-01" -Headers $headers -ContentType 'application/json' -Body $body | Out-Null
Write-Output "Dedicated fixture VM is running; phase=$phase."
'@
foreach ($phase in @('Failover', 'Reprotect')) {
$runbookId = "$automationId/runbooks/Drills-$phase"
Invoke-FixtureArm -Method PUT -Path "$runbookId`?api-version=2023-11-01" -Body @{
location = $location
properties = @{ runbookType = 'PowerShell'; logProgress = $false; logVerbose = $false; description = 'Isolated Drills lifecycle fixture' }
} | Out-Null
$content = $runbookTemplate.Replace('__VM_ID__', $vmId).Replace('__PHASE__', $phase)
Invoke-FixtureArm -Method PUT -Path "$runbookId/draft/content?api-version=2023-11-01" -ContentType 'text/powershell' -Body $content | Out-Null
Invoke-FixtureArm -Method POST -Path "$runbookId/publish?api-version=2023-11-01" | Out-Null
}
# Newly discovered service-group members briefly fail readiness until discovery settles.
Invoke-FixtureAction -Path "$planId/checkReadiness?api-version=$apiVersion" -RetryOnErrorCodes @('RHUBUserErrorAzureResourceAddedToServiceGroup') -MaxAttempts 6
Write-Host 'Waiting for the dedicated VM to appear in recovery-resource discovery.'
$deadline = [DateTime]::UtcNow.AddMinutes(15)
do {
$resources = Get-FixtureResource -Path "$planId/recoveryResources?api-version=$apiVersion"
$target = $resources.value | Where-Object { $_.properties.resourceId -eq $vmId }
if ($target) { break }
$validation = (Get-FixtureResource -Path "$planId`?api-version=$apiVersion").properties.latestValidationStatus
if ($validation.errorDetails.code -eq 'RHRORecoveryResourceSyncFailed') {
throw "Recovery-resource discovery failed: $(ConvertTo-Json $validation -Depth 10 -Compress)"
}
Start-Sleep -Seconds 15
} while ([DateTime]::UtcNow -lt $deadline)
if (@($target).Count -ne 1) { throw 'Expected exactly one recovery resource for the dedicated test VM.' }
# Drill setup creates Chaos resources in this group, so new members can appear between the
# update and finalize calls. Re-read the members and retry until finalize sees a stable set.
for ($attempt = 1; ; $attempt++) {
$resources = Get-FixtureResource -Path "$planId/recoveryResources?api-version=$apiVersion"
$updates = @($resources.value | ForEach-Object {
$properties = @{ recoveryResourceUniqueId = $_.name; inclusionState = 'Excluded' }
if ($_.id -eq $target.id) {
$properties.inclusionState = 'Included'
$properties.recoveryGroupId = $plan.properties.recoveryGroupsSetting.defaultGroup.properties.groupUniqueId
$properties.selectedProtectionSolutionType = 'CustomRunbook'
$properties.selectedProtectionSolutionSetting = @{
protectionSolutionType = 'CustomRunbook'
failoverAction = @{ resourceId = "$automationId/runbooks/Drills-Failover" }
reprotectAction = @{ resourceId = "$automationId/runbooks/Drills-Reprotect" }
}
}
@{ id = $_.id; properties = $properties }
})
Invoke-FixtureAction -Path "$planId/updateResources?api-version=$apiVersion" -Body @{
resourcesToUpdate = $updates
resourcesToRemove = @()
}
Wait-FixtureResource -Path "$planId`?api-version=$apiVersion" | Out-Null
try {
Invoke-FixtureAction -Path "$planId/finalize?api-version=$apiVersion"
break
}
catch {
if ($attempt -ge 4 -or !$_.Exception.Message.Contains('RHUBUserErrorAzureResourceAddedToServiceGroup')) { throw }
Write-Warning 'New service-group members were discovered before finalize; excluding them and retrying.'
Start-Sleep -Seconds 60
}
}
$included = @((Get-FixtureResource -Path "$planId/recoveryResources?api-version=$apiVersion").value |
Where-Object { $_.properties.inclusionState -eq 'Included' })
if ($included.Count -ne 1 -or $included[0].properties.resourceId -ne $vmId) {
throw 'Only the dedicated fixture VM may be included in the recovery plan.'
}
# 3) Health Model monitoring for the GA drill configuration tests.
$healthApi = '2026-05-01-preview'
Invoke-FixtureArm -Method PUT -Path "$healthModelId/authenticationsettings/default-auth?api-version=$healthApi" -Body @{
properties = @{
displayName = 'Fixture managed identity'
authenticationKind = 'ManagedIdentity'
managedIdentityName = 'SystemAssigned'
}
} | Out-Null
$healthModelName = $healthModelId.Split('/')[-1]
Invoke-FixtureArm -Method PUT -Path "$healthModelId/discoveryrules/$healthModelName`?api-version=$healthApi" -Body @{
properties = @{
displayName = 'Dedicated fixture service group'
authenticationSetting = 'default-auth'
discoverRelationships = 'Enabled'
addRecommendedSignals = 'Enabled'
addResourceHealthSignal = 'Enabled'
specification = @{
kind = 'ResourceGraphQuery'
resourceGraphQuery = "relationshipresources | where type =~ 'microsoft.relationships/servicegroupmember' | where tostring(properties.TargetId) =~ '$serviceGroupId' | project id=tostring(properties.SourceId)"
}
}
} | Out-Null
# 4) The drill. The second PUT runs after the drill identity holds its roles.
$drillProperties = @{
drillType = 'Zonal'
rbacSetupMode = 'AutomatedBuiltinRoles'
drillAssetProperties = @{ subscription = $SubscriptionId; region = $location; resourceGroup = $resourceGroupName }
recoveryPlanProperties = @{ recoveryPlanId = $planId; identity = @{ type = 'SystemAssigned' } }
chaosResourceProperties = @{
identity = @{ type = 'SystemAssigned' }
chaosResourceIdentityForFaults = @{ type = 'SystemAssigned' }
}
monitoringProperties = @{ identity = @{ type = 'SystemAssigned' } }
goalAssignmentProperties = @{ identity = @{ type = 'SystemAssigned' } }
healthModelMonitoringProperties = @{ identity = @{ type = 'SystemAssigned' }; healthModelId = $healthModelId }
}
$drill = New-FixtureResource -Path "$drillId`?api-version=$apiVersion" -Body @{
identity = @{ type = 'SystemAssigned' }
properties = $drillProperties
}
Add-FixtureRole -Scope $serviceGroupId -PrincipalId $drill.identity.principalId -RoleId '517781b0-5ad4-4418-94d5-f2421834b586'
Add-FixtureRole -Scope "/subscriptions/$SubscriptionId" -PrincipalId $drill.identity.principalId -RoleId '344b740c-5e7c-4d32-89b2-ee3b51cb89bc'
Start-Sleep -Seconds 60
$response = Invoke-FixtureArm -Method PUT -Path "$drillId`?api-version=$apiVersion" -Body @{
identity = @{ type = 'SystemAssigned' }
properties = $drillProperties
}
Wait-FixtureOperation -Response $response -Path $drillId
$drill = Wait-FixtureResource -Path "$drillId`?api-version=$apiVersion"
if ($drill.properties.goalAssignmentProperties.identity.type -ne 'SystemAssigned' -or
$drill.properties.healthModelMonitoringProperties.healthModelId -ne $healthModelId) {
throw 'The GA drill response did not preserve the requested goal/Health Model configuration. Resolve the service response-contract mismatch before lifecycle testing.'
}
Invoke-FixtureAction -Path "$drillId/resyncReadinessCheck?api-version=$apiVersion"
$deadline = [DateTime]::UtcNow.AddMinutes(15)
do {
$drillResources = Get-FixtureResource -Path "$drillId/drillResources?api-version=$apiVersion"
$drillTarget = $drillResources.value | Where-Object { $_.properties.resourceId -eq $vmId }
if ($drillTarget) { break }
Start-Sleep -Seconds 15
} while ([DateTime]::UtcNow -lt $deadline)
if (@($drillTarget).Count -ne 1) { throw 'Expected exactly one drill resource for the dedicated test VM.' }
$addOrUpdateBody = @{
faultDurationInMin = 1
forceInclusionAndUpdate = 'Disable'
resourceLists = @{
includeResources = @(@{ id = $drillTarget.id })
excludeResources = @($drillResources.value | Where-Object { $_.properties.resourceId -ne $vmId } | ForEach-Object { $_.id })
updateResources = @()
}
}
try {
Invoke-FixtureAction -Path "$drillId/addOrUpdateResources?api-version=$apiVersion" -Body $addOrUpdateBody
}
catch {
if (!$_.Exception.Message.Contains('RHDSChaosResourceValidationFailed')) { throw }
# The service asks for its supported fixup when the first Chaos scenario configuration fails.
Write-Warning 'Chaos scenario configuration failed; running the supported resync fixup and retrying once.'
Invoke-FixtureAction -Path "$drillId/resyncReadinessCheck?api-version=$apiVersion"
Invoke-FixtureAction -Path "$drillId/addOrUpdateResources?api-version=$apiVersion" -Body $addOrUpdateBody
}
$drillTarget = Get-FixtureResource -Path "$($drillTarget.id)?api-version=$apiVersion"
if ($drillTarget.properties.inclusionState -ne 'Included') {
throw "The dedicated VM was not included: $(ConvertTo-Json $drillTarget.properties -Depth 15 -Compress)"
}
$sourceLocation = @($target.properties.resourcePhysicalZones)
if ($sourceLocation.Count -ne 1) { throw 'Expected exactly one physical source zone for the dedicated VM.' }
# 5) A completed drill run for the read-only drill-run tests. Drill-run resources exist only after
# failover, so the run completes a full manual failover and reprotect cycle on the dedicated VM
# and leaves the drill NotRunning for the destructive lifecycle tests.
Wait-FixturePlanReady
$runsPath = "$drillId/drillRuns?api-version=$apiVersion"
$existingRunIds = @((Get-FixtureResource -Path $runsPath).value | ForEach-Object { $_.id })
Invoke-FixtureAction -Path "$drillId/start?api-version=$apiVersion" -Body @{ mode = 'Failover' }
$deadline = [DateTime]::UtcNow.AddMinutes(15)
do {
$drillRun = (Get-FixtureResource -Path $runsPath).value | Where-Object { $_.id -notin $existingRunIds } | Select-Object -First 1
if ($drillRun) { break }
Start-Sleep -Seconds 15
} while ([DateTime]::UtcNow -lt $deadline)
if (!$drillRun) { throw "No drill run appeared after starting drill $drillName." }
$runId = "$drillId/drillRuns/$($drillRun.name)"
Invoke-DrillRunVerb -Path "$runId/failOver?api-version=$apiVersion" -Body @{
autoFailover = 'Disable'
failoverProperties = @{
failoverDirection = 'FromSpecificLocations'
failoverRequestProperties = @{ sourceLocations = @($sourceLocation[0]) }
}
}
Wait-DrillRunVerb -RunId $runId -Stage 'FaultInjection' -Verbs 'MarkAsComplete'
Invoke-DrillRunVerb -Path "$runId/markAsComplete?api-version=$apiVersion" -Body @{ drillRunStage = 'FaultInjection' }
Wait-DrillRunVerb -RunId $runId -Stage 'Failover' -Verbs 'Start'
Invoke-DrillRunVerb -Path "$runId/resume?api-version=$apiVersion"
Wait-DrillRunVerb -RunId $runId -Stage 'Failover' -Verbs 'MarkAsComplete'
Invoke-DrillRunVerb -Path "$runId/markAsComplete?api-version=$apiVersion" -Body @{ drillRunStage = 'Failover' }
Wait-DrillRunVerb -RunId $runId -Stage 'Reprotect' -Verbs 'Start', 'Retry'
# The service accepts a reprotect without a body but never processes it.
Invoke-DrillRunVerb -Path "$runId/reprotect?api-version=$apiVersion" -Body @{ reprotectProperties = @{} }
Wait-DrillRunVerb -RunId $runId -Stage 'Reprotect' -Verbs 'MarkAsComplete'
$drillRunTarget = (Get-FixtureResource -Path "$runId/drillRunResources?api-version=$apiVersion").value | Select-Object -First 1
if (!$drillRunTarget) { throw "No drill run resource appeared for drill run $($drillRun.name)." }
Invoke-DrillRunVerb -Path "$runId/markAsComplete?api-version=$apiVersion" -Body @{ drillRunStage = 'Reprotect' }
$endBody = @{ attestation = 'Success'; attestationNotes = 'Azure MCP live-test fixture run.' }
for ($attempt = 1; ; $attempt++) {
try {
Invoke-FixtureAction -Path "$drillId/end?api-version=$apiVersion" -Body $endBody
break
}
catch {
# End conflicts while the start operation is still settling.
if ($attempt -ge 20 -or !$_.Exception.Message.Contains('(409)')) { throw }
Start-Sleep -Seconds 15
}
}
$deadline = [DateTime]::UtcNow.AddMinutes(15)
do {
$drill = Get-FixtureResource -Path "$drillId`?api-version=$apiVersion"
if ($drill.properties.executionState -eq 'NotRunning') { break }
Start-Sleep -Seconds 15
} while ([DateTime]::UtcNow -lt $deadline)
if ($drill.properties.executionState -ne 'NotRunning') { throw "Drill $drillName did not return to NotRunning after the fixture run." }
# The lifecycle tests run further forward failover drills, which require a Ready plan.
Wait-FixturePlanReady
$DeploymentOutputs['DRILLSDRILLRESOURCEID'] = $drillTarget.id
$DeploymentOutputs['DRILLSSOURCELOCATION'] = $sourceLocation[0]
$DeploymentOutputs['DRILLSRECOVERYRESOURCEID'] = $target.id
$DeploymentOutputs['DRILLSDRILLRUNNAME'] = $drillRun.name
$DeploymentOutputs['DRILLSDRILLRUNRESOURCENAME'] = $drillRunTarget.name
Write-Host "Drills fixture ready (service group: $serviceGroupName, drill: $drillName, completed run: $($drillRun.name))."
@@ -0,0 +1,195 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// cspell:ignore mcpfixture
// Deployed by test-resources.bicep into the dedicated drills resource group created by
// test-resources-pre.ps1. Keeping these resources out of the main group stops the general
// service group from discovering the fault-injection VM.
targetScope = 'resourceGroup'
param baseName string
param adminUsername string = 'mcpfixture'
@secure()
param adminPassword string = 'P${newGuid()}!'
param location string = resourceGroup().location
param healthModelLocation string = 'centralus'
param deploySliWorkspace bool = false
param vmSize string = 'Standard_D2s_v5'
param drillName string = ''
var suffix = uniqueString(resourceGroup().id, baseName)
var effectiveDrillName = empty(drillName) ? 'dr-${suffix}' : drillName
var fixtureTags = {
purpose: 'mcp-drills-ga-live-tests'
}
resource network 'Microsoft.Network/virtualNetworks@2024-05-01' = {
name: 'vnet-${suffix}'
location: location
tags: fixtureTags
properties: {
addressSpace: {
addressPrefixes: [
'10.214.0.0/24'
]
}
subnets: [
{
name: 'fixture'
properties: {
addressPrefix: '10.214.0.0/24'
defaultOutboundAccess: false
}
}
]
}
}
resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = {
name: 'nic-${suffix}'
location: location
tags: fixtureTags
properties: {
ipConfigurations: [
{
name: 'private'
properties: {
privateIPAllocationMethod: 'Dynamic'
subnet: {
id: '${network.id}/subnets/fixture'
}
}
}
]
}
}
// Only this private, dedicated VM may be included in lifecycle fault injection.
resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = {
name: 'vm-${suffix}'
location: location
zones: [
'1'
]
tags: fixtureTags
properties: {
hardwareProfile: {
vmSize: vmSize
}
storageProfile: {
imageReference: {
publisher: 'Canonical'
offer: '0001-com-ubuntu-server-jammy'
sku: '22_04-lts-gen2'
version: 'latest'
}
osDisk: {
createOption: 'FromImage'
deleteOption: 'Delete'
managedDisk: {
storageAccountType: 'StandardSSD_LRS'
}
}
}
osProfile: {
computerName: 'drill-fixture'
adminUsername: adminUsername
adminPassword: adminPassword
linuxConfiguration: {
disablePasswordAuthentication: false
}
}
networkProfile: {
networkInterfaces: [
{
id: nic.id
properties: {
deleteOption: 'Delete'
}
}
]
}
}
}
resource automation 'Microsoft.Automation/automationAccounts@2023-11-01' = {
name: 'aa-${suffix}'
location: location
tags: fixtureTags
identity: {
type: 'SystemAssigned'
}
properties: {
disableLocalAuth: true
publicNetworkAccess: false
sku: {
name: 'Basic'
}
}
}
resource vmOperator 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(vm.id, automation.id, 'Virtual Machine Contributor')
scope: vm
properties: {
principalId: automation.identity.principalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '9980e02c-c2be-4d73-94e8-173b1dc7cf3c')
}
}
resource usagePlan 'Microsoft.AzureResilienceManagement/usagePlans@2026-10-01' = {
name: 'up-${suffix}'
location: 'global'
tags: fixtureTags
properties: {
planType: 'Standard'
}
}
resource healthModel 'Microsoft.CloudHealth/healthmodels@2026-05-01-preview' = {
name: 'hm-${suffix}'
location: healthModelLocation
tags: fixtureTags
identity: {
type: 'SystemAssigned'
}
properties: {}
}
resource healthReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(resourceGroup().id, healthModel.id, 'Reader')
properties: {
principalId: healthModel.identity.principalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')
}
}
resource workspace 'Microsoft.Monitor/accounts@2023-04-03' = if (deploySliWorkspace) {
name: 'amw-${suffix}'
location: location
tags: fixtureTags
properties: {
publicNetworkAccess: 'Enabled'
}
}
resource sliIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: 'sli-${suffix}'
location: location
tags: fixtureTags
}
output usagePlanId string = usagePlan.id
output usagePlanName string = usagePlan.name
output serviceGroupName string = 'sgd-${suffix}'
output lifecycleServiceGroupName string = 'sgl-${suffix}'
output recoveryPlanName string = 'rp-${suffix}'
output drillName string = effectiveDrillName
output vmId string = vm.id
output automationAccountId string = automation.id
output healthModelId string = healthModel.id
output monitorWorkspaceId string? = deploySliWorkspace ? workspace!.id : null
output sliIdentityId string = sliIdentity.id
output location string = location
@@ -44,27 +44,21 @@ $planLifecycleEnrollmentName = $DeploymentOutputs['PLANLIFECYCLEENROLLMENTNAME']
$planLifecycleServiceGroupName = $DeploymentOutputs['PLANLIFECYCLESERVICEGROUPNAME']
$workflowEnrollmentName = $DeploymentOutputs['WORKFLOWENROLLMENTNAME']
$workflowServiceGroupName = $DeploymentOutputs['WORKFLOWSERVICEGROUPNAME']
$goalTemplateName = $DeploymentOutputs['GOALTEMPLATENAME']
$goalAssignmentName = $DeploymentOutputs['GOALASSIGNMENTNAME']
$recoveryPlanName = $DeploymentOutputs['RECOVERYPLANNAME']
$workflowRecoveryPlanName = $DeploymentOutputs['WORKFLOWRECOVERYPLANNAME']
$drillName = $DeploymentOutputs['DRILLNAME']
$deleteDrillName = $DeploymentOutputs['DELETEDRILLNAME']
$storageAccountId = $DeploymentOutputs['STORAGEACCOUNTID']
$automationAccountName = $DeploymentOutputs['AUTOMATIONACCOUNTNAME']
$automationAccountId = $DeploymentOutputs['AUTOMATIONACCOUNTID']
$failoverRunbookName = $DeploymentOutputs['FAILOVERRUNBOOKNAME']
$reprotectRunbookName = $DeploymentOutputs['REPROTECTRUNBOOKNAME']
$location = $DeploymentOutputs['LOCATION']
$serviceGroupApiVersion = '2024-02-01-preview'
$membershipApiVersion = '2023-09-01-preview'
$resilienceApiVersion = '2026-04-01-preview'
$resilienceApiVersion = '2026-10-01'
$serviceGroupId = "/providers/Microsoft.Management/serviceGroups/$serviceGroupName"
$serviceGroupResilienceBase = "$serviceGroupId/providers/Microsoft.AzureResilienceManagement"
$lifecycleServiceGroupId = "/providers/Microsoft.Management/serviceGroups/$lifecycleServiceGroupName"
$lifecycleServiceGroupResilienceBase = "$lifecycleServiceGroupId/providers/Microsoft.AzureResilienceManagement"
$planLifecycleServiceGroupId = "/providers/Microsoft.Management/serviceGroups/$planLifecycleServiceGroupName"
$workflowServiceGroupId = "/providers/Microsoft.Management/serviceGroups/$workflowServiceGroupName"
$workflowServiceGroupResilienceBase = "$workflowServiceGroupId/providers/Microsoft.AzureResilienceManagement"
@@ -309,6 +303,7 @@ Wait-ResilienceProvisioning -Path $workflowServiceGroupPath -WaitForAuthorizatio
Add-RecoveryContributorRole -Scope $serviceGroupId
Add-RecoveryContributorRole -Scope $lifecycleServiceGroupId
Add-RecoveryContributorRole -Scope $planLifecycleServiceGroupId
Add-RecoveryContributorRole -Scope $workflowServiceGroupId
Add-DrillsAdministratorRole -Scope "/subscriptions/$subscriptionId"
# 2) Add the resource group as a member of the service group so its resources
@@ -374,36 +369,21 @@ Invoke-ResilienceRestPut -Path $workflowEnrollmentPath -Body @{
} | Out-Null
Wait-ResilienceProvisioning -Path $workflowEnrollmentPath
# 4) Create a goal template on the service group.
$goalTemplatePath = "$serviceGroupResilienceBase/goalTemplates/$goalTemplateName`?api-version=$resilienceApiVersion"
Invoke-ResilienceRestPut -Path $goalTemplatePath -Body @{
properties = @{
goalType = 'Resiliency'
requireHighAvailability = 'Required'
requireDisasterRecovery = 'NotRequired'
regionalRecoveryPointObjective = 'PT15M'
regionalRecoveryTimeObjective = 'PT30M'
}
} | Out-Null
Wait-ResilienceProvisioning -Path $goalTemplatePath -WaitForAuthorization
# 5) Assign the goal template to the service group.
# 4) Create a GA goal assignment directly on the service group.
$goalAssignmentPath = "$serviceGroupResilienceBase/goalAssignments/$goalAssignmentName`?api-version=$resilienceApiVersion"
$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
requireZonalResiliency = $true
serviceLevelResources = @()
}
} | Out-Null
Wait-ResilienceProvisioning -Path $goalAssignmentPath -WaitForAuthorization
}
elseif ($existingGoalAssignment.StatusCode -eq 200) {
$goalAssignment = $existingGoalAssignment.Content | ConvertFrom-Json
if ($goalAssignment.properties.goalAssignmentType -ne 'Resiliency' -or
$goalAssignment.properties.goalTemplateId -ne $goalTemplateId -or
if ($goalAssignment.properties.requireZonalResiliency -ne $true -or
$goalAssignment.properties.provisioningState -ne 'Succeeded') {
throw "Existing goal assignment '$goalAssignmentName' does not match the requested test configuration."
}
@@ -413,7 +393,7 @@ else {
throw "GET $goalAssignmentPath failed with status $($existingGoalAssignment.StatusCode): $($existingGoalAssignment.Content)"
}
# 6) Create or validate the recoveryplan on the service group. Do not PUT an
# 5) Create or validate the recoveryplan on the service group. Do not PUT an
# existing plan because recovery group IDs are referenced by its recovery resources.
$recoveryPlanPath = "$serviceGroupResilienceBase/recoveryPlans/$recoveryPlanName`?api-version=$resilienceApiVersion"
$existingRecoveryPlan = Invoke-AzRestMethod -Method GET -Path $recoveryPlanPath
@@ -610,243 +590,93 @@ Wait-ResilienceProvisioning -Path $workflowRecoveryPlanPath -WaitForAuthorizatio
$DeploymentOutputs['WORKFLOWRECOVERYRESOURCEID'] = $workflowResource.id
New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot | Out-Null
# 8) Create a drill. The service creates its drillResources from the service-group membership.
$drillPath = "$serviceGroupResilienceBase/drills/$drillName`?api-version=$resilienceApiVersion"
$recoveryPlanId = "$serviceGroupResilienceBase/recoveryPlans/$recoveryPlanName"
$existingDrill = Invoke-AzRestMethod -Method GET -Path $drillPath
if ($existingDrill.StatusCode -eq 404) {
Invoke-ResilienceRestPut -Path $drillPath -Body @{
identity = @{
type = 'SystemAssigned'
}
properties = @{
drillType = 'Zonal'
rbacSetupMode = 'AutomatedBuiltinRoles'
recoveryPlanProperties = @{
recoveryPlanId = $recoveryPlanId
identity = @{ type = 'SystemAssigned' }
}
drillAssetProperties = @{
subscription = $subscriptionId
region = $location
resourceGroup = $ResourceGroupName
}
chaosResourceProperties = @{
identity = @{ type = 'SystemAssigned' }
chaosResourceIdentityForFaults = @{ type = 'SystemAssigned' }
}
monitoringProperties = @{
identity = @{ type = 'SystemAssigned' }
}
}
} | Out-Null
Wait-ResilienceProvisioning -Path $drillPath -WaitForAuthorization
}
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 $location -or
$drill.properties.drillAssetProperties.resourceGroup -ne $ResourceGroupName -or
$drill.properties.recoveryPlanProperties.recoveryPlanId -ne $recoveryPlanId -or
$drill.properties.monitoringProperties.identity.type -ne 'SystemAssigned' -or
$drill.properties.provisioningState -ne 'Succeeded') {
throw "Existing drill '$drillName' does not match the requested test configuration."
# Exclude any other newly discovered member resources before readiness-dependent workflow tests run.
$workflowResources = ((Invoke-AzRestMethod -Method GET -Path $workflowResourcesPath).Content | ConvertFrom-Json).value
$unresolvedWorkflowResources = @(
$workflowResources | Where-Object {
$_.id -ne $workflowResource.id -and $_.properties.inclusionState -ne 'Excluded'
}
Write-Host "Drill '$drillName' already exists with the requested configuration."
}
else {
throw "GET $drillPath failed with status $($existingDrill.StatusCode): $($existingDrill.Content)"
}
# 8b) Create an isolated recoveryplan and drill used exclusively by the delete live test.
$deleteRecoveryPlanPath = "$lifecycleServiceGroupResilienceBase/recoveryPlans/$recoveryPlanName`?api-version=$resilienceApiVersion"
if ((Invoke-AzRestMethod -Method GET -Path $deleteRecoveryPlanPath).StatusCode -eq 404) {
Invoke-ResilienceRestPut -Path $deleteRecoveryPlanPath -Body @{
identity = @{
type = 'SystemAssigned'
}
properties = @{
planDescription = 'Recoveryplan for the drill delete live test.'
planType = 'Zonal'
recoveryGroupsSetting = @{
defaultGroup = @{
properties = @{
description = 'Default recovery group'
groupUniqueId = (New-Guid).Guid
orderId = 0
preActions = @()
postActions = @()
}
)
if ($unresolvedWorkflowResources.Count -gt 0) {
$workflowResourceUpdates = @(
$unresolvedWorkflowResources | ForEach-Object {
@{
id = $_.id
properties = @{
recoveryResourceUniqueId = $_.name
inclusionState = 'Excluded'
}
additionalGroups = @()
}
}
)
Invoke-ResilienceRestPost -Path $workflowUpdatePath -OperationId (New-Guid).Guid -Body @{
resourcesToUpdate = $workflowResourceUpdates
resourcesToRemove = @()
} | Out-Null
Wait-ResilienceProvisioning -Path $deleteRecoveryPlanPath -WaitForAuthorization
}
Wait-ResilienceProvisioning -Path $workflowRecoveryPlanPath -WaitForAuthorization
$deleteDrillPath = "$lifecycleServiceGroupResilienceBase/drills/$deleteDrillName`?api-version=$resilienceApiVersion"
$deleteRecoveryPlanId = "$lifecycleServiceGroupResilienceBase/recoveryPlans/$recoveryPlanName"
if ((Invoke-AzRestMethod -Method GET -Path $deleteDrillPath).StatusCode -eq 404) {
Invoke-ResilienceRestPut -Path $deleteDrillPath -Body @{
identity = @{
type = 'SystemAssigned'
$workflowFinalizeOperationId = (New-Guid).Guid
for ($attempt = 0; $attempt -lt 40; $attempt++) {
$workflowFinalizeResponse = az rest --method POST --url "https://management.azure.com$workflowFinalizePath" --headers "operation-id=$workflowFinalizeOperationId" --output json 2>&1
if ($LASTEXITCODE -eq 0) {
break
}
properties = @{
drillType = 'Zonal'
rbacSetupMode = 'AutomatedBuiltinRoles'
recoveryPlanProperties = @{
recoveryPlanId = $deleteRecoveryPlanId
identity = @{ type = 'SystemAssigned' }
}
drillAssetProperties = @{
subscription = $subscriptionId
region = $location
resourceGroup = $ResourceGroupName
}
chaosResourceProperties = @{
identity = @{ type = 'SystemAssigned' }
chaosResourceIdentityForFaults = @{ type = 'SystemAssigned' }
}
monitoringProperties = @{
identity = @{ type = 'SystemAssigned' }
}
if ($workflowFinalizeResponse -notmatch 'InvalidResourceOperation') {
throw "POST $workflowFinalizePath failed: $workflowFinalizeResponse"
}
} | Out-Null
Wait-ResilienceProvisioning -Path $deleteDrillPath -WaitForAuthorization
}
# Wait for the drill resource created from the storage-account service-group member.
$drillResourcesPath = "$serviceGroupResilienceBase/drills/$drillName/drillResources`?api-version=$resilienceApiVersion"
$drillResource = $null
$deadline = (Get-Date).AddSeconds(600)
while (-not $drillResource -and (Get-Date) -lt $deadline) {
$response = Invoke-AzRestMethod -Method GET -Path $drillResourcesPath
if ($response.StatusCode -ge 400) {
throw "GET $drillResourcesPath failed with status $($response.StatusCode): $($response.Content)"
}
$drillResources = ($response.Content | ConvertFrom-Json).value
$drillResource = $drillResources | Where-Object {
$_.properties.resourceId -eq $storageAccountId
} | Select-Object -First 1
if (-not $drillResource) {
Write-Host " waiting for the storage account drill resource to appear..."
Start-Sleep -Seconds 15
}
}
if (-not $drillResource) {
throw "No drill resource was created for storage account $storageAccountId."
}
$DeploymentOutputs['DRILLRESOURCENAME'] = $drillResource.name
# 9) Include the storage account in the drill, preserving the faults discovered by the service.
$includeResource = @{
id = $drillResource.id
}
if ($drillResource.properties.faultProperties) {
$includeResource['faultProperties'] = $drillResource.properties.faultProperties
}
$addResourcesPath = "$serviceGroupResilienceBase/drills/$drillName/addOrUpdateResources`?api-version=$resilienceApiVersion"
Invoke-ResilienceRestPost -Path $addResourcesPath -OperationId (New-Guid).Guid -Body @{
faultDurationInMin = 1
forceInclusionAndUpdate = 'Enable'
resourceLists = @{
includeResources = @($includeResource)
excludeResources = @()
updateResources = @()
if ($LASTEXITCODE -ne 0) {
throw "POST $workflowFinalizePath did not acquire the recoveryplan operation lock: $workflowFinalizeResponse"
}
} | Out-Null
}
$drillResourcePath = "$($drillResource.id)`?api-version=$resilienceApiVersion"
$deadline = (Get-Date).AddSeconds(900)
do {
$response = Invoke-AzRestMethod -Method GET -Path $drillResourcePath
if ($response.StatusCode -ge 400) {
throw "GET $drillResourcePath failed with status $($response.StatusCode): $($response.Content)"
}
$includedResource = $response.Content | ConvertFrom-Json
if ($includedResource.properties.inclusionState -eq 'Included') {
$workflowJobsPath = "$workflowServiceGroupResilienceBase/recoveryPlans/$workflowRecoveryPlanName/recoveryJobs`?api-version=$resilienceApiVersion"
$existingWorkflowJobNames = @(
((Invoke-AzRestMethod -Method GET -Path $workflowJobsPath).Content | ConvertFrom-Json).value.name
)
$workflowReadinessPath = "$workflowServiceGroupResilienceBase/recoveryPlans/$workflowRecoveryPlanName/checkReadiness`?api-version=$resilienceApiVersion"
$workflowReadinessOperationId = (New-Guid).Guid
for ($attempt = 0; $attempt -lt 40; $attempt++) {
$workflowReadinessResponse = az rest --method POST --url "https://management.azure.com$workflowReadinessPath" --headers "operation-id=$workflowReadinessOperationId" --output json 2>&1
if ($LASTEXITCODE -eq 0) {
break
}
Write-Host " drill resource inclusionState = $($includedResource.properties.inclusionState)"
if ($workflowReadinessResponse -notmatch 'InvalidResourceOperation') {
throw "POST $workflowReadinessPath failed: $workflowReadinessResponse"
}
Start-Sleep -Seconds 15
} while ((Get-Date) -lt $deadline)
if ($includedResource.properties.inclusionState -ne 'Included') {
throw "Drill resource $($drillResource.name) was not included before the timeout."
}
if ($LASTEXITCODE -ne 0) {
throw "POST $workflowReadinessPath did not acquire the recoveryplan operation lock: $workflowReadinessResponse"
}
# 10) Start the drill and discover the server-generated drill run.
$drillRunsPath = "$serviceGroupResilienceBase/drills/$drillName/drillRuns`?api-version=$resilienceApiVersion"
$existingRunIds = @()
$existingRunsResponse = Invoke-AzRestMethod -Method GET -Path $drillRunsPath
if ($existingRunsResponse.StatusCode -lt 400) {
$existingRunIds = @((($existingRunsResponse.Content | ConvertFrom-Json).value).id)
}
$startPath = "$serviceGroupResilienceBase/drills/$drillName/start`?api-version=$resilienceApiVersion"
Invoke-ResilienceRestPost -Path $startPath -OperationId (New-Guid).Guid -Body @{
mode = 'Failover'
} | Out-Null
$drillRun = $null
$deadline = (Get-Date).AddSeconds(900)
while (-not $drillRun -and (Get-Date) -lt $deadline) {
$response = Invoke-AzRestMethod -Method GET -Path $drillRunsPath
if ($response.StatusCode -ge 400) {
throw "GET $drillRunsPath failed with status $($response.StatusCode): $($response.Content)"
}
$runs = ($response.Content | ConvertFrom-Json).value
$drillRun = $runs | Where-Object { $_.id -notin $existingRunIds } | Select-Object -First 1
if (-not $drillRun) {
Write-Host " waiting for the drill run to appear..."
$deadline = (Get-Date).AddSeconds(600)
while ((Get-Date) -lt $deadline) {
$workflowJobs = ((Invoke-AzRestMethod -Method GET -Path $workflowJobsPath).Content | ConvertFrom-Json).value
$workflowReadinessJob = $workflowJobs |
Where-Object { $_.name -notin $existingWorkflowJobNames } |
Sort-Object { [DateTime]$_.properties.startTime } -Descending |
Select-Object -First 1
if (-not $workflowReadinessJob -or $workflowReadinessJob.properties.status -notin @('Completed', 'Succeeded', 'Failed', 'Canceled')) {
Start-Sleep -Seconds 15
continue
}
}
if (-not $drillRun) {
throw "No drill run appeared after starting drill $drillName."
}
$DeploymentOutputs['DRILLRUNNAME'] = $drillRun.name
$DeploymentOutputs['MARKCOMPLETESERVICEGROUP'] = $serviceGroupName
$DeploymentOutputs['MARKCOMPLETEDRILL'] = $drillName
$DeploymentOutputs['MARKCOMPLETEDRILLRUN'] = $drillRun.name
# Capture a run target for the drill run resource live tests.
$drillRunResourcesPath = "$serviceGroupResilienceBase/drills/$drillName/drillRuns/$($drillRun.name)/drillRunTargets`?api-version=$resilienceApiVersion"
$drillRunResource = $null
$deadline = (Get-Date).AddSeconds(900)
while (-not $drillRunResource -and (Get-Date) -lt $deadline) {
$response = Invoke-AzRestMethod -Method GET -Path $drillRunResourcesPath
if ($response.StatusCode -ge 400) {
throw "GET $drillRunResourcesPath failed with status $($response.StatusCode): $($response.Content)"
}
$drillRunResources = ($response.Content | ConvertFrom-Json).value
$drillRunResource = $drillRunResources | Select-Object -First 1
if (-not $drillRunResource) {
Write-Host " waiting for the drill run target to appear..."
Start-Sleep -Seconds 15
if ($workflowReadinessJob.properties.status -notin @('Completed', 'Succeeded')) {
$errorDetails = $workflowReadinessJob.properties.errorDetails
throw "Workflow readiness job '$($workflowReadinessJob.name)' ended in state '$($workflowReadinessJob.properties.status)'. ErrorCode: $($errorDetails.errorCode). Message: $($errorDetails.errorMessage)"
}
break
}
if (-not $workflowReadinessJob -or $workflowReadinessJob.properties.status -notin @('Completed', 'Succeeded')) {
throw "Workflow readiness did not complete before the timeout."
}
if (-not $drillRunResource) {
throw "No drill run target appeared for drill run $($drillRun.name)."
}
$DeploymentOutputs['DRILLRUNRESOURCENAME'] = $drillRunResource.name
# Configure the VM-backed drills fixture that test-resources.bicep deploys into its dedicated
# resource group. It adds DRILLS* outputs used by the destructive drill lifecycle tests.
& "$PSScriptRoot/test-resources-drills-post.ps1" -DeploymentOutputs $DeploymentOutputs -SubscriptionId $subscriptionId -TenantId $tenantId
New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot | Out-Null
Write-Host "Resilience test resources are ready (service group: $serviceGroupName, usage plan: $usagePlanName, enrollment: $enrollmentName, goal template: $goalTemplateName, goal assignment: $goalAssignmentName, recoveryplan: $recoveryPlanName, drill: $drillName, drill run: $($drillRun.name))."
Write-Host "Resilience test resources are ready (service group: $serviceGroupName, usage plan: $usagePlanName, enrollment: $enrollmentName, goal assignment: $goalAssignmentName, recoveryplan: $recoveryPlanName, drills service group: $($DeploymentOutputs['DRILLSSERVICEGROUPNAME']))."
@@ -0,0 +1,32 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Invoked by New-TestResources.ps1 before test-resources.bicep is deployed. The drills module
# deploys into a dedicated resource group, which must exist before the main deployment.
param(
[string] $ResourceGroupName
)
$ErrorActionPreference = 'Stop'
$mainGroup = Get-AzResourceGroup -Name $ResourceGroupName
$tags = @{}
if ($mainGroup.Tags) {
foreach ($key in $mainGroup.Tags.Keys) { $tags[$key] = $mainGroup.Tags[$key] }
}
# Fixture scripts refuse to configure or delete groups without this tag.
$tags['Purpose'] = 'mcp-drills-ga-live-tests'
$drillsGroupName = "$ResourceGroupName-drills"
foreach ($name in @($drillsGroupName, "$drillsGroupName-delete")) {
$existing = Get-AzResourceGroup -Name $name -ErrorAction SilentlyContinue
if ($existing) {
if ($existing.Tags['Purpose'] -ne 'mcp-drills-ga-live-tests') {
throw "Refusing to reuse resource group '$name' without the dedicated drills fixture tag."
}
continue
}
Write-Host "Creating drills fixture resource group '$name' in '$($mainGroup.Location)'."
New-AzResourceGroup -Name $name -Location $mainGroup.Location -Tag $tags | Out-Null
}
@@ -1,6 +1,8 @@
targetScope = 'resourceGroup'
param baseName string = resourceGroup().name
// Created by test-resources-pre.ps1 before this deployment.
param drillsResourceGroupName string = '${resourceGroup().name}-drills'
// Deterministic, schema-valid names.
// Usage plan and enrollment names must match ^[a-zA-Z0-9-]{3,24}$.
@@ -14,12 +16,9 @@ var planLifecycleEnrollmentName = take('ep${uniqueSuffix}', 24)
var planLifecycleServiceGroupName = take('sgp${uniqueSuffix}', 24)
var workflowEnrollmentName = take('ew${uniqueSuffix}', 24)
var workflowServiceGroupName = take('sgw${uniqueSuffix}', 24)
var goalTemplateName = take('gt${uniqueSuffix}', 24)
var goalAssignmentName = take('ga${uniqueSuffix}', 24)
var recoveryPlanName = take('rp${uniqueSuffix}', 24)
var workflowRecoveryPlanName = take('rw${uniqueSuffix}', 24)
var drillName = take('dr${uniqueSuffix}', 24)
var deleteDrillName = take('dd${uniqueSuffix}', 24)
var storageAccountName = toLower(take('st${uniqueSuffix}', 24))
var managedDiskName = take('md${uniqueSuffix}', 80)
var automationAccountName = take('aa${uniqueSuffix}', 50)
@@ -85,7 +84,7 @@ resource automationAccount 'Microsoft.Automation/automationAccounts@2023-11-01'
}
// Usage plan (resource-group scoped). This resource type is only available in the 'global' location.
resource usagePlan 'Microsoft.AzureResilienceManagement/usagePlans@2026-04-01-preview' = {
resource usagePlan 'Microsoft.AzureResilienceManagement/usagePlans@2026-10-01' = {
name: usagePlanName
location: 'global'
properties: {
@@ -102,12 +101,9 @@ output planLifecycleEnrollmentName string = planLifecycleEnrollmentName
output planLifecycleServiceGroupName string = planLifecycleServiceGroupName
output workflowEnrollmentName string = workflowEnrollmentName
output workflowServiceGroupName string = workflowServiceGroupName
output goalTemplateName string = goalTemplateName
output goalAssignmentName string = goalAssignmentName
output recoveryPlanName string = recoveryPlanName
output workflowRecoveryPlanName string = workflowRecoveryPlanName
output drillName string = drillName
output deleteDrillName string = deleteDrillName
output storageAccountName string = storageAccountName
output storageAccountId string = storageAccount.id
output automationAccountName string = automationAccountName
@@ -115,3 +111,25 @@ output automationAccountId string = automationAccount.id
output failoverRunbookName string = failoverRunbookName
output reprotectRunbookName string = reprotectRunbookName
output location string = resourceGroup().location
// Destructive drill lifecycle tests use a VM-backed fixture in a dedicated resource group so the
// general service group above never discovers the fault-injection target.
module drills 'test-resources-drills.bicep' = {
name: 'drills-fixture'
scope: resourceGroup(drillsResourceGroupName)
params: {
baseName: baseName
location: resourceGroup().location
}
}
output drillsResourceGroupName string = drillsResourceGroupName
output drillsDeleteAssetResourceGroupName string = '${drillsResourceGroupName}-delete'
output drillsUsagePlanId string = drills.outputs.usagePlanId
output drillsServiceGroupName string = drills.outputs.serviceGroupName
output drillsLifecycleServiceGroupName string = drills.outputs.lifecycleServiceGroupName
output drillsRecoveryPlanName string = drills.outputs.recoveryPlanName
output drillsDrillName string = drills.outputs.drillName
output drillsVmId string = drills.outputs.vmId
output drillsAutomationAccountId string = drills.outputs.automationAccountId
output drillsHealthModelId string = drills.outputs.healthModelId